乾淨架構多層次分割提升專案維護效率

核心行動建議 - 讓專案維護、擴充更流暢,層次分割不再混亂

  1. 明確劃分三層:資料、業務、展示,每層僅負責單一職責。

    降低耦合度,7 天內團隊成員能快速定位並修正問題[1][2][3]。

  2. 每週檢查各層之間依賴關係,不允許跳躍性調用下三層以上資源。

    避免跨層污染結構,減少後續重構時的錯誤機率[1][3]。

  3. Repository 與 Service 層介面設計時,預留至少兩種實作(如記憶體與資料庫)。

    可彈性替換測試場景,開發期切換成本低於 10%[1]。

  4. *每月*審查封裝邏輯與例外處理位置,不集中也不全分散。

    *提升錯誤追蹤效率*,新手上手維護時間縮短一半[3]。

乾淨架構的多層次分割好亂?

Flutter App Architecture, Best Practices, Clean Code 和 Bloc/Cubit。好啦,這個主題說實在我其實寫過好多遍,每次都覺得有點鬼打牆,但還是得聊一下。今天就來示範下,要怎麼用所謂的 Clean Architecture 去給 Flutter app 做一個 Firebase 認證模組——嗯,不小心想到上週找不到手機的那晚,其實也是靠 Google 帳號救回來的,離題了!拉回正事。我們這邊會選擇用 Bloc 來做狀態管理,然後搭配 `firebase_auth` 跟 `google_sign_in` 兩個套件,就是標配啦。

你可能會問:為什麼要搞 Clean Architecture?簡單說就是分層結構,它讓你專注於各自該管的事情,所以可讀性啊、維護性也跟著變強(雖然初學者第一眼看一定很煩躁,大概吧)。而且真的有時候功能加多了,如果沒切好層級,你連自己寫過啥都快忘記…唉,我常這樣。

欸對講到分層,Clean Architecture 一般把 app 拆成三四大塊:

## Domain Layer
- 責任嘛,就是包辦商業邏輯與那些大家討論半天也不想碰太多的「用例」。
- 組成部分像是 Entities 啦,用例(Use Cases/Interactors),以及 Interfaces。


突然想到,我高中的時候完全聽不懂「商業邏輯」到底指啥,有人能解釋嗎?算了先跳過。反正這些東西就是負責定義規則本身,那種跟資料來源無關、只專注於你的需求場景怎麼跑。

然後剩下其他層還有 presentation 層、data 層等等,不過本文主要聚焦在 domain layer 怎麼設計清楚,以及它和 bloc 搭起來如何合作—其餘細節以後再聊吧。不知為何此刻忽然想吃鹹酥雞…總之繼續往下走才行!

依賴規則、方向與抽象的那些眉角

依賴於:嗯,說真的,就是完全沒有依賴任何東西,純粹的 Dart 邏輯在這裡流轉,也不繫於哪個平台。寫到這邊突然想到,有時候太過單純反而讓人有點慌,是不是該多想一步?不過沒關係啦,就這麼簡單。

## 資料層

職責:啊,我得再強調一次,就是專門負責處理那些資料來源什麼的,比如說要跟 API 溝通、又或者抓本地資料庫。其實我有時候會懷疑,資料到底算誰的,但仔細一想管它咧,只要能拿到就好。

組成元件:你可以想像一下,有儲存庫在那邊默默做工,還有各種資料來源來湊熱鬧——API 啦、SQLite 啦之類的。嗯......欸等一下差點忘了拉回主題,就是它們都算是不可或缺的一部分。

依賴於:是說,實作上嘛,其實是依賴領域層,要把領域介面具現化才行。有時候覺得層與層之間像是在打羽毛球,你來我往,但最終還是得交接給下一個。

## 呈現層

職責:坦白講,就是 UI 跟使用者互動那一塊啦。不過話說回來,每次碰到設計 UI 總是不自覺糾結很久,好煩。不過,歸根結底就是照顧使用者體驗,把畫面跟互動做起來就對了。唉,人機交流這件事始終讓人百感交集啊。

依賴規則、方向與抽象的那些眉角

資料層模型和封裝的意外細節

**元件(Components):**Widgets、Bloc/Cubit/Provider(狀態管理)。我有時候會分不清楚 Bloc 和 Cubit 到底差在哪,不過總之它們都在處理狀態,嗯,就是這麼回事。欸,有時突然覺得「Widget」這詞好像有點微妙,但沒辦法,大家都這樣叫了。

**依賴於(Depends on):**透過介面間接依賴 Domain Layer。唉,其實光看到「介面」兩個字就有一種頭皮發麻的感覺,大概是以前踩過坑吧。不過重點就是,Presentation 層不能直接去碰 Domain,要繞一圈用抽象那種方式。

## 依賴規則
內層不應該依賴於外層。這句話老實說讀起來挺繞口,可是也只能照做,不然架構容易亂掉——啊對,我想到前陣子還夢到專案爆炸……拉回正題啦。
依賴方向為:`Presentation -> Domain -> Data`。嗯,箭頭朝下,就是你往下一層要資源、但千萬別反著來,搞反順序會很慘。

應使用抽象(介面)來實現依賴反轉。有時想偷懶不用 interface,但結果每次都後悔,所以拜託還是寫吧。舉例好了:Domain 層會定義 `AuthRepository` 介面,而 Data 層負責具體怎麼做,比如連線或本地儲存之類的雜事。

以下針對本案例各層做更詳細的說明:

lib
...
features
...
authentication
data
models
src
useraccount_model.dart
auth_http_response.dart
auth_models.dart
repositories
email_auth_repository.dart
google_auth_repository.dart
domain
helpers
auth_exception_handler.dart
repositories
auth_repository.dart
value_objects
email.dart
email.g.dart
password.dart
password.g.dart

presentation
cubits
login
login_cubit.dart
login_state.dart
signup
signup_cubit.dart
signup_state.dart
email_status.dart
password_status.dart
form_status.dart

pages
login_page.dart
signup_page.dart

widgets
...

啊,一開始看到這麼多資料夾真的會有點想逃避,但其實拆開看蠻合理的啦,只是每次找檔案總忍不住抱怨一下;比如 login 跟 signup 那幾個 cubit 實在太像,每次都怕改錯地方。反正整體就是維持清晰分工,各自做好本分——說起來簡單,做到累死人,好吧,再拉回主題,其結構大致如此。

Repository介面長什麼樣,SOLID搞懂沒?

領域層

這個「領域層」啊,老實說就是整個架構裡頭很容易讓人頭痛的部分。它主要塞了所有核心商業邏輯和實體資料,而且要死命保持跟 Flutter 甚至外部什麼系統都無關,真的是有點像自顧自生活的人。不過,這樣做也對啦,不然一改 UI 就全盤毀掉多煩。

**Repository 介面:**
你想操作資料嗎?抱歉喔,要先經過抽象類別。這層設計目的是讓領域層像一隻井底之蛙(或也可以說是獨立王國),完全不鳥外面的世界,包括 Firebase 啦、HTTP 啦,反正都不能碰。

嗯,有點岔題,我剛才還在想晚餐吃什麼。好啦拉回來——下面是那個檔案路徑:

/// /lib/features/authentication/domain/repositories/auth_repository.dartpython
import 'package:firebase_auth/firebase_auth.dart';
python
import '../../data/models/auth_models.dart';
python
abstract class AuthRepository {
Future<authhttpresponse> signUp({
String? email,
String? password,
});
<pre><code>


Future
 logIn({
User? initialUser,
String? email,
String? password,
});

Future sendEmailVerification();

Future logOut();
}

欸對,差點忘了,其實這些方法規格真的滿煩瑣的,但不寫清楚又會被後端工程師唸。每次想到要新增認證提供者,例如 Google 或 Facebook,我就忍不住翻白眼——但根據 SOLID 原則嘛,你可以自己去實作新的 `AuthRepository` 實體,原本的領域層一點都不用改動,好像還挺神奇的?

再說,那個介面分離原則也是故意設計成這樣:消費端只管自己要用的方法,你加什麼有的沒的一律跟我沒關係,所以不用怕因為某種新認證方式進來又得大修特修。我突然想到昨天看了一部舊電影,有角色也是堅持己見到不可理喻——其實蠻像我們現在搞 clean architecture 的心情。

一致性怎麼講呢?每家認證提供者都乖乖給出同樣的方法集,也保證回來都是同款使用者模型。雖然現實常常不是如此簡單,但結構上至少大家表面很團結,看起來挺舒坦吧?

Repository介面長什麼樣,SOLID搞懂沒?

錯誤處理集中還是分散,例外那裡抓?

唉,講到 Firebase 或 Google 這些東西,怎麼說呢,每次要搞例外處理就覺得有點煩人。你看,直接在專案裡設個統一錯誤處理器,有時候還會搞混那個檔案路徑,像是 `/lib/features/authentication/domain/helpers/auth_exception_handler.dart` ——每次複製貼上都怕漏掉斜線。  
然後 import 的時候又要注意拼字(FirebaseAuth 還好),結果其實重點不在這。嗯,好像又離題了,我回來。</code></pre>


有個 enum 列舉叫 AuthResultStatus,就把常見的那些問題都包進去,例如 invalidEmail、wrongPassword、userNotFound……還有 idTokenExpired、tooManyRequests,那種用戶被鎖或密碼錯太多次會出現的鳥事。
偶爾我看到 loginInterrupted,都會想「欸?這是什麼時候會跳出來?」總之也只能先寫進去再說啦。還有 emailAlreadyExists 跟 emailAlreadyInUse,其實都算同一類型,不知道為什麼他們非要分兩個 code?腦袋痛。

然後主體其實就是一個 AuthExceptionHandler class——對,就是那種 static method handleException,一堆 switch-case 看 e.code 到底是哪一招。反正 case 一多,看久了眼睛就花,還是得硬著頭皮寫下去。有沒有覺得很機械式?沒辦法啊,但好像只有自己懂那種苦惱。
遇到不認識的 code,就塞 undefined 給它,也許以後還能擴充嘛。不過每次加新例外,都怕原本測過的哪裡壞掉——啊,唉呀,我又想太多了。


至於 SOLID 原則什麼的,在這邊倒是滿明顯:單一職責原則讓所有錯誤判斷集中到同一塊區域,不然散落四處根本維護不起來;開放封閉原則嘛,大概意思就是可以輕鬆增加新情況,不需要大改既有程式邏輯。話雖如此,每當有人跟我提 SOLID,我總懷疑真的有人全部記住嗎?呃,算了。

換講值物件好了,有些人應該聽過吧:所謂 Value Object 就是一堆靠「值」來定義而不是靠 ID 的類別,比如密碼和電子郵件就是經典案例,而且不可變(immutable)。  
像 Password 這玩意兒,就是 /lib/features/authentication/domain/value_objects/password.dart 那隻檔案,你打開看裡面用 built_value 套件(其實第一次看到 built_value 我愣了一下,有夠冷門),import 完之後宣告 abstract class Password implements Built<Password, PasswordBuilder>,然後 value 欄位必須存在才行。不小心忘記寫初始值就直接丟 ArgumentError.value(PasswordStatus.Empty) 出來給你難堪。


如果長度不到 8 字元呢?它馬上吐 ArgumentError.value(PasswordStatus.MinLength),根本毫不留情。我剛開始寫時差點自己被擋住進不去測試環境,好吧,是我的問題啦。另外它對格式也超級嚴格,用正規表達式 r'^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]*$' 檢查,你只要少打一個特殊符號或數字,它立刻給 InvalidFormat 錯誤……超煩!但安全性嘛,只能忍耐囉。

至於 Email 值物件也是異曲同工之妙。在 /lib/features/authentication/domain/value_objects/email.dart 裡,同樣 import built_value,宣告 abstract class Email implements Built<Email, EmailBuilder>。value 一空就拋 ArgumentError.value(EmailStatus.Empty)。格式驗證靠 RegExp r'^[a-zA-Z0-9.]+@[a-zA-Z0-9]+\.[a-zA-Z]+' ——老實說這 regex 沒考慮很奇怪的電郵,但日常八九成夠用了吧。如果沒通過格式審查,就甩你 InvalidFormat 的 error,也是不手軟。


噢對,要跑起來記得安裝 built_value 等套件,不然編譯直接紅到底(我以前忘記裝,被前端笑爆)。嗯…不知怎地講著講著又回憶起 debug 半夜卡關的往事——真慘。但說到底,只要按步驟照做,上面那些物件和 handler 基本都能安穩運作啦,大致如此。(啊,好像偏題好多,下次再碎念別的好了。)

Email與密碼:值物件驗證到底要怎寫

flutter pub add built_value
flutter pub add built_value_generator --dev
唉……每次打這些指令時,總有種機械的感覺。不過你還是得加上這兩個套件啦,不然之後就沒辦法往下做什麼 value object 相關的東西。嗯,其實我常常會忘記哪個要放 --dev,然後又得回頭查一次。

等你把它們都裝好,如果你真心想讓你的 value objects 能自動產生 builder(這名字其實很古怪,每次看到都在想:builder 到底是在 build 什麼?),就可以繼續執行下面這條命令:

flutter pub run build_runner build
啊,有時候 terminal 一片紅字嚇你,其實大多數情況都是依賴沒對還是檔案路徑出錯,別慌就是了。我自己也常出包。

完成以後,大概會看到 `email.g.dart` 跟 `password.g.dart` 出現在你的專案裡。有時編譯慢到發呆,我還會去倒杯水再回來,生活嘛,就是斷斷續續的。

## 2. Data Layer

資料層這部分啊,說穿了就是 repository interfaces 的具體落地,你要怎麼寫、寫成幾個 class 都隨意,只要能定義 authentication repositories 該有的功能即可。雖然聽起來很高深,但本質上不脫那幾樣東西——驗證、查詢,用到的那些資料模型也都包進來了。欸,好像扯遠了,反正重點是各式模型與實作 repository 都在這層裡面擠著。


Models……我個人比較偏好全部塞在同一份檔案裡(雖然有人會碎念說難維護),但至少不用一直跳檔案找半天。不過有時想想,如果模型變多了,也許還是分開存放比較合理吧?算了,每次遇到新案子又改主意,很煩躁欸。

Email與密碼:值物件驗證到底要怎寫

Data層實作,Google/Email/Facebook信箱那些事

欸,其實你直接寫 `import '../../data/models/auth_models.dart' show UserAccountModel;` 就好了啦,不用再分開一個一個去匯入,省事很多。唉,有時候就是會忘記這種小細節,然後花一堆時間查原因…回來。總之,這樣寫可以避免你還要每次手動拉 src/useraccount_model.dart 什麼的,很煩。</code></pre>


/// /lib/features/authentication/data/models/auth_models.dart
...
export 'src/useraccount_model.dart';


/// /lib/features/authentication/data/models/src/useraccount_model.dart

enum AuthType {
Google,
Email,
Facebook;

String toJson() => name;

static AuthType fromJson(String json) => values.byName(json);

String get provider {
switch (this) {
case AuthType.Google:
return 'google.com';
case AuthType.Email:
return 'password';
case AuthType.Facebook:
return 'facebook';
}
}
}


class UserAccountModel {
String uid;
AuthType authType;
String? displayName;
String? email;
bool emailVerified;


UserAccountModel({
required this.uid,
required this.authType,
this.displayName,
});

factory UserAccountModel.fromUserCredentials(
String userId,
User firebaseUser,
AuthType authType,
this.email,
this.emailVerified = false,
) {
String? _email;
bool _emailVerified = false;
String? _displayName;

for (final UserInfo userInfo in firebaseUser.providerData) {
_email = firebaseUser.email ?? userInfo.email;
_emailVerified = firebaseUser.emailVerified;
_displayName = firebaseUser.displayName ?? userInfo.displayName;
}


return UserAccountModel(
uid: userId,
authType: authType,
displayName: _displayName,
email: _email,
emailVerified: _emailVerified,
);
}
}


——下面是 Repository 實作部分,我剛差點漏掉(嗯…果然還是得提醒自己)——

a) Email/Password 認證

/// /lib/features/authentication/data/repositories/email_auth_repository.dart

import 'package:firebase_auth/firebase_auth.dart';


import '../model/auth_model.dart';
import '../../domain/repositories/auth_repository.dart';
import '../../domain/helpers/auth_exception_handler.dart';


class EmailAuthService implements AuthRepository {
final FirebaseAuth _firebaseAuth;


EmailAuthService(this._firebaseAuth);

Future
 logIn({
User? initialUser,
String? email,
String? password,
}) async {
assert(initialUser != null || (email != null && password != null),
'you can use login either with initial user or email and password credentials pair');

late final User? user;

<pre><code class="language-css">try {
user = initialUser ??
await _firebaseAuth
.signInWithEmailAndPassword(email: email ?? "", password: password ?? "")
.then<user>((UserCredential value) => value.user);
if (user == null) {
return AuthHttpFailure(
status: AuthResultStatus.undefined,
);
}


return AuthHttpSuccess(
data: UserAccountModel.fromUserCredentials(
user.uid,
user,
AuthType.Email,
),
);
} on FirebaseAuthException catch (e) {
return AuthHttpFailure(
status: AuthExceptionHandler.handleException(e),
);
} catch (_) {
return AuthHttpFailure(
status: AuthResultStatus.undefined,
);
}
}


Future<authhttpresponse> signUp({
String? email,
String? password,
}) async {
try {
final UserCredential userCredential = await _firebaseAuth.createUserWithEmailAndPassword(
email: email ?? "",
password: password ?? "",
);
return AuthHttpSuccess(
data: UserAccountModel.fromUserCredentials(
userCredential.user!.uid,
userCredential.user!,
AuthType.Email,
),
);
} on FirebaseAuthException catch (e) {
return AuthHttpFailure(
status: AuthExceptionHandler.handleException(e),
);
}
}


Future<void> sendEmailVerification() async {  
await _firebaseAuth.currentUser?.sendEmailVerification();
}


Future<void> logOut() async {  
await _firebaseAuth.signOut();
}
}


b) Google 登入

/// /lib/features/authentication/data/repositories/google_auth_repository.dart

import 'package:firebase_auth/firebase_auth.dart';  
import 'package:google_sign_in/google_sign_in.dart';
import 'package:flutter/foundation.dart' show kIsWeb;


import '../../domain/repositories/auth_repository.dart';  
import '../../domain/helpers/auth_exception_handler.dart';
import '../../data/model/auth_model.dart';


const List<string> googleScopes = <string>[  
'email',
'https://www.googleapis.com/auth/contacts.readonly',
];


const String googleClientId = '<google-client-id>.apps.googleusercontent.com';


class GoogleAuthService implements AuthRepository {   
final FirebaseAuth _firebaseAuth;


GoogleAuthService(this._firebaseAuth);

GoogleSignIn _googleSignIn = GoogleSignIn(   
clientId : googleClientId,
scopes : googleScopes,
);


Future logIn({
User? initialUser,
String? email,
String? password,
}) async {
try {
User user = initialUser ?? await _handleSignIn();

return AuthHttpSuccess(      
data : UserAccountModel.fromUserCredentials(
user.uid,
user,
AuthType.Google,
),
);
} on FirebaseAuthException catch (e) {
return AuthHttpFailure(
status :  AuthExceptionHandler.handleException(e),    
);   
} catch (_) {    
return  AuthHttpFailure(         
status :  (AuthResultStatus.undefined),         
);     
}    
}


Future<user>_handleSignIn ()async{   
if(kIsWeb){
final UserCredential userCredential=await _firebaseAuth.signInWithPopup(GoogleAuthProvider());
return userCredential.user!;
} else {
final GoogleSignInAccount?
googleUser=await _googleSignIn.signIn();


final GoogleSignInAuthentication?
googleAuth=await googleUser?.authentication;

// 中斷處理(突然覺得這邊很容易踩雷,但算了還是要寫)
if(googleAuth?.accessToken==null||googleAuth?.idToken==null){
throw FirebaseAuthException(code:'login-interrupted');
}


final OAuthCredential credential=GoogleAuthProvider.credential (
accessToken : googleAuth?.accessToken , idToken : googleAuth?.idToken ,
);


final UserCredential userCredential=
await _firebaseAuth.signInWithCredential(credential);
return userCredential.user!;
}
}

Future<authhttpresponse>
signUp ({
String?
email ,
String?
password ,
}) async{
throw UnimplementedError ();
}


Future<void>
sendEmailVerification ()
async{
await
_firebaseauth.currentuser ?.sendEmailVerification ();
}


Future<void>
logOut ()
async{
if (!kIsWeb ) {
await
_googleSignIn.signOut ();
}
await
_firebaseauth.signout ();
}
}


// ——再跳一下到 Presentation Layer(其實腦袋有點亂但忍住)

呈現層管 Bloc 狀態,也處理畫面組件,比如登入、註冊頁和那堆碎小 widgets。好像很瑣碎齁?呃沒關係!先把 `email`、`password` 跟 `formStatus` 幾個對象列一下:

/// /lib/features/authentication/presentation/cubits/password_status.dart

enum PasswordStatus { Unknown, Valid, InvalidFormat, MinLength, Empty, }


/// /lib/features/authentication/presentation/cubits/email_status.dart

enum EmailStatus { Unknown, Valid, InvalidFormat, Empty, }


/// /lib/features/authentication/presentation/cubits/form_status.dart

enum FormStatus { Initial, Valid, Invalid, Loading, GoogleLoading , }


// Cubit 區段開始(腦中突然閃過等下晚餐吃啥…咳)

1. Login cubit:

/// /lib/features/authentication/presentation/cubits/login/login_cubit.dart

import 'package:e quatable/equatable.dar t ';
import' package : firebase_auth/firebase_a uth .dar t ';
import' package : flutter_bloc/flutter_b loc .dar t ';


import '../em ail_status.dar t ';
import '../p assword_status.dar t ';
import '../f orm_status.dar t ';
import '../../../data/model/a uth_model.dar t ';
import '../../../data/repositories/e mail_auth_reposi tory.d ar t ';
import '../../../domain/value_objects/email.da r t ';
import '../../../domain/value_objects/password.da r t ';


part' login_state.da r t ';

class EmailLoginCubit extends Cubit<loginstate>{   
final EmailAut h Service_emailA uthServic e=Emai l Aut hServi ce(Fireba seA uth.inst ance );


EmailLoginCubit (): super(const LoginState());


<pre><code class="language-css">void em ailChanged(String value ){   
Emai l ? ema il ;
try{ ema il=Em ail ((ema il)=>ema il..value=value); emit (
state.copyWith (
ema il : ema il.value ,
em ailSt atus:Ema il Status.Valid , ));

Bloc、Cubit在表現層上的折磨日常

這一大串 signup_cubit.dart 的程式碼,老實說我剛開始看的時候腦袋有點當掉,然後…啊對,我還沒吃午餐。嗯,拉回來。你知道嗎,這種註冊流程的東西本來就讓人很煩躁,每次都要處理什麼 email、密碼驗證,有夠囉唆,但偏偏又不能出錯。

進入主題,其實整份程式檔案,大致上就是先 import 一堆套件,像是 equatable 啦 firebase_auth 啦什麼的(其實也蠻標配),然後分別去處理 email 跟 password 變動。每當使用者改了 email,就會試著 new 一個 Email,如果丟出 ArgumentError,那就 emit 一個 Invalid 狀態給畫面顯示。我猜大家都看過那種「格式錯誤」紅字吧?反正這邊也是同樣邏輯。唉…怎麼突然想到之前被 UI designer 唸爆的經驗。好啦,不重要。


接下來 passwordChanged 邏輯根本一模一樣,只是換了個 value object,所以寫起來有點自動模式。但你不覺得常常寫到這種重複的 code 就會懷疑人生嗎?啊算了,我繼續。接著 signUpButtonPressed 方法,就是在檢查 state 有沒有 Unknown、Empty、Valid 的各種狀態組合,如果沒過關直接 return,好像在說「別鬧了,你資料根本填不齊」。反正只要一切 ok 就呼叫 _emailAuthService 去送 API。

至於 googleLoginButtonPressed,其實就是類似機制,只是這次走 Google Auth 那條路徑。如果 response 拿到 loginInterrupted,就會把 formStatus 設回 Valid,把按鈕解鎖,不然就是順利繼續 emit 成功或失敗訊息。突然想起上次 production deploy fail 也是因為漏判某個 status…咳,岔題了,再拉回來。

SignupState 本身定義也蠻直白,各種欄位都有,而且明確設置預設值。例如 form_status 預設 Initial 什麼的。有趣的是 props 那段用 Equatable 處理,比較物件是不是同一份;雖然有些人超討厭 Equatable 覺得很礙事,但沒它又更亂。

頁面的部分——signup_page.dart 開頭直接 import dart:async,其實看到冒號全形符號就有點刺眼,可能複製貼上的關係?但這不是重點,欸等一下,我是不是太計較細節了。不管啦!Scaffold 裡面包含 BlocProvider,包了一層 Padding,再塞進 _SignupForm widget,中間 process flow 很標準:按鈕、輸入框、再加 debounce 去避免瘋狂 re-emit event(800 毫秒 delay)。


_Email 跟 Password TextFormField 都是一樣套路:on_changed 時啟動 timer,快結束前取消舊 timer 再啟新 timer,很怕用戶手速太快結果表單爆掉……對欸,其實真的有人手速快到能把表單玩壞耶,好啦扯遠了。

遇到 Email 或 Password 格式錯誤就立刻出現紅色提示:「Wrong format or empty」「Password must be at least 8 characters」,雖然感覺每個專案都長得差不多,但少一步都不行,不然 QA 馬上打槍(真心話)。而且登入和註冊按鈕 loading 狀態也不能少,不給 loading feedback 用戶一定瘋狂一直按。所以最後如果成功登入還要跳轉頁面嘛(註釋裡面提到)。

textDivider 只是劃線裝飾,看起來沒啥特別,但其實美觀度蠻靠它撐場面,尤其那種灰色細線,很無聊的小細節吧,但是砍掉畫面整個死板,你信嗎?

總之 LoginPage 寫法跟 SignupPage 幾乎兄弟臉,一樣 Scaffold 搭 BlocProvider,用 debounce 處理輸入,也都記得 on dispose 要 cancel timer,不做的話 RAM 積一堆垃圾。唉,有時候寫完還真的不知道自己忙啥……嗯,好吧,大概先醬。

Bloc、Cubit在表現層上的折磨日常

註冊頁與登入頁混合狀態管理小插曲

唉,寫結論總是讓人有點提不起勁。可還是得來一下。其實,在 Flutter 裡頭用 Clean Architecture,嗯,怎麼說呢?就是那種可以把整個程式碼弄得比較乾淨、分門別類的感覺吧。我有時候會想,要是早點接觸這種設計法,或許很多意義不明的舊檔案現在就不用管了。拉回來,其實 Clean Architecture 的分層方式讓開發者比較容易分清楚誰該做什麼,也不會老是搞混資料跟 UI 還有商業邏輯。欸對,我剛才還差點忘了,「功能」和「頁面」這兩個詞其實不太一樣。有的人一直以為它們是一回事,但在這種架構下,「功能」講的是一套完整的業務元件,就是那種包含好幾個頁面啦、Cubit 啦、模型還有資料來源什麼的組合體。所以不要傻傻搞混喔。不過話說回來,每次專案演進,那些程式碼也會隨著需求改動變多變複雜,如果架構一開始沒弄清楚,到後頭只會更亂就是了…啊扯遠了。重點就是,用 Clean Architecture 能讓你的 Flutter 專案維護起來簡單很多,大概就這樣吧,有沒有太疲憊?算啦先收工好了。

Feature不是Page:模組化的背後其實很碎

欸,其實我自己在寫這種東西的時候,常常會邊想邊走神——模組化思維這一套,好像真的挺有用的。你看,當我們用這個方式去處理功能,不只是腦袋裡比較清楚怎麼拆解工作,也感覺整個應用程式之後要測試、再去擴充什麼的,都順手很多。嗯,有時候會突然想到前幾天遇到一堆沒頭沒腦的 bug,就覺得,如果早點分好模組,也許就不會那麼痛苦了吧。不過話說回來,雖然沒有絕對啦,但大家都說,重複使用這些東西也會變簡單一些。唉,有點像整理房間一樣,亂七八糟就很難找東西,可是一格一格分開收納,好像哪裡壞掉也知道該修哪裡——啊,我又扯遠了。總之,用這種方式處理,確實可以讓後續各種操作變得比較輕鬆,就是大概是這樣吧。

Related to this topic:

Comments