# Flutter workshop 此 Workshop 目的是分享 app 開發的流程,包含架構、常用的套件、工具,以及開發一個需求時的 SOP, 讓大家在未來的合作上能有共識。提供的概念和方法不一定是最好的,但是我個人偏好上用起來最順手的,若日後有新的改進想法都歡迎提出討論。 > 內容涉略的範圍太廣,時間有限下,每個主題只能概略介紹,但會提供相關說明文章,需要大家後續花一些時間去閱讀才能對每個主題有更深入的瞭解。 ### Objectives - Environment Setup and Project Layout - Stateless and Stateful Widget's Lifecycle - Data class / Unions / Pattern-matching - Functional error handling - State management - Routing - Testing - Localization - Mason package (template generator) - CI / CD ### Environment Setup and Project Layout Flutter 是跨平台框架,但要建置不同平台的 apps 還是要仰賴原生的軟體,比如要安裝 Android Studio, XCode 等等,參考這裡 :point_right: [verygood.ventures](https://verygood.ventures/blog/very-good-flutter-setup) 有很完善的環境設定教學。 #### Project layout 寫 app 的第一步就是要思考如何架構整個專案,除了能讓團隊有同樣的共識去開發需求外,好的架構也能提高專案的品質 (維護、重用、測試、擴充) ```sh ├── lib │ ├── constant │ ├── features │ ├── l10n │ ├── routing │ ├── services │ ├── utils │ ├── widgets │ └── main.dart ``` - constant: 管理整個專案的常數 - features: 管理 app 中所有功能需求 - l10n: 管理在地化語言檔案 - routing: 管裡 routes, router - services: 管理專案中高度共用的服務 (比如 REST API Service) - utils: 管理用於整個專案中的小工具或 helper function - widgets: 專案中共用的元件 - main.dart: The entry point of the application. ```sh ├── lib │ ├── features │ │ ├── feature1 │ │ │ ├── presentation │ │ │ ├── application │ │ │ ├── domain │ │ │ └── data │ │ └── feature2 │ │ │ ├── presentation │ │ │ ├── application │ │ │ ├── domain │ │ │ └── data ``` Feature-first (layers inside feature) - 一個 feature 底下可視情況分為四層: - data layer: 存放 repositories, 專注於將外部或 local data 轉為 app 看得懂的 domain models, 處理的資料比如透過 remote APIs 取得的 json, 或本地的 local database, 或甚至手機上的 gps, bluetooth, etc. - domain layer: 存放 feature 相關的 models - application layer: 存放 providers, controllers, 從 data layer 存取 data 並做邏輯處理 & 狀態管理 (cache / manipulate states) - presentation layer: 存放 widgets (UI) 供使用者互動,依賴 application layer 的狀態而顯示不同的 UI ![](https://i.imgur.com/laqGF5c.png) - 該怎麼定義 feature 一開始在定義 feature 時很容易會將一個頁面視為一個 feature, 但事實並非如此,一個頁面可能會含有多個 features, 以及一個 feature 可能會在不同頁面中存在,所以在思考 feature 時應該考慮的不是使用者「看到什麼」而是使用者「做了什麼」,也就是說 feature 應是一個功能需求,來幫助使用者完成特定的任務,且不受限於特定的頁面 ### Stateless and Stateful Widget’s Lifecycle Flutter 的 UI 是由多個 Widget 組成的 Widget tree,又分為 StatelessWidget 和 StatefulWidget - StatelessWidget 的狀態無法改變,只需實作 build() 方法, 渲染完就下班了,隨著從 Widget tree 抽離後生命週期就結束 - StatefulWidget 帶有狀態,生命週期如下: - createState(): 創建 StatefulWidget 時,需要一個 createState() 方法來返回與 widget 相關聯的 State 的實例 - mounted: 判斷 widget 是否在 widget tree 上,當 buildContext 被賦值給 widget 時,轉為 true - initState(): 在 build() 之前會先被調用的方法,可以在此方法內初始化 build() 方法裡所需的變量 - didChangeDependencies(): 在 initState() 方法之後調用,也就是當 state 對象的依賴關係改變時被調用。可被多次調用 - build(): 建構 UI,在 initState() 方法之後調用。當調用 setState() 方法來重新調用 - didUpdateWidget(Widget oldWidget): 當 StatefulWidget 建構子中的屬性更新時被調用,提供 oldWidget 和 newWidget - setState(): 當需要更新 UI 時調用的方法。透過它來改變狀態、重新觸發 build() 方法 - deactivate(): 當 widget 被暫時抽離(pop),但在當前頁面中可能被重新注入時調用。 - dispose(): 當 widget 完全從 widget tree 中移除時調用 - Sample code ### Data class / Unions / Pattern-matching #### Immutability ```dart class Person { String name; int age; Person({required this.name, required this.age}); } class ImmutablePerson { final String name; final int age; const ImmutablePerson({required this.name, required this.age}); } void main() { final person = Person(name: 'Jesse', age: 20); person.name = 'Jason'; // ok final immutablePerson = ImmutablePerson(name: 'Jesse', age: 20); immutablePerson.name = 'Jason' // Compile error } ``` - 解決問題:避免狀態在任意時間/空間上被「意外」的改變,mutable 意味 class 裡的屬性可以被任意更改,一但意外地改變發生時,bug 很難追朔,使程式變得更不可預測 - Q: 如果一個狀態 (class) 是 immutable, 那如何更新 app 狀態? - Immutable class (class 裡屬性為 final), 一旦 class 被建構後,其帶有的屬性 (final) 被賦予一次後就不能再被更新,所以要更新 immutable state 的方式不是去改變 state 裡面的值,而是直接「替換」掉它,copy 一個新的 instance, 但若只想更新某個屬性呢?為此,會寫一個 copyWith 的方法 (Copy an instance with specific properties.) #### Value equality - Referential equality:兩個物件在 memory 中是指到同一個位址上 (i.e., const objects) - Value equality:兩個物件中的屬性變量相等,不論兩個物件是不是在同一個 memory 位址上 - 解決問題 - 檢查狀態 (class) 的正確性:確認 app 的狀態是否如預期,會用 == 去比較兩個 class 是否相等,又或在寫測試時,會用 expect(actual, matcher) 來檢查 actual 與 matcher 是否相等. By default, Dart 在比較兩個物件時 (使用 ==), 檢查的是 referential equality, 也就是若比較兩個非 const 物件時,即便裡面的屬性數值都一樣也會回傳 false, 有時並非所有物件都可以 const 形式存在,因此要做到 value equality 就要去覆寫物件裡的 equality operator (==) 和 hashCode 等等 - 避免不必要的 update / rebuild:在 app 中可能會有很多地方去監聽某個狀態的改變去 rebuild UI 或做其他事,「狀態的改變」就是透過 equality 去檢查,若物件不具有 value equality 就會造成即便物件內的屬性數值都相等也會觸發狀態更新,造成不必要的 update / rebuild 事件發生 #### Union types / Pattern-matching - 解決問題:讓狀態管理變得輕鬆,增加程式碼可讀性,Pattern-matching 能強制你處理 Union 裡的所有狀態,解決傳統上使用 if / switch-case 的方式去檢查狀態時,可能會遺漏掉某些狀態 - Union types:讓一個物件可以有多個狀態,可在不同時間點賦予不同狀態 - Pattern-matching:提供 `when` `maybeWhen` `map` `maybeMap` 等方法來窮舉 Union 的狀態, 再針對特定狀態做對應的事,好處是程式碼易讀,也能避免漏掉某些狀態沒檢查到 為了做到上述的每件事情,必須寫很多很多 code, 但 [freezed](https://pub.dev/packages/freezed) 直接幫我們解決這些問題,freezed class 提供 `Immutability` `Value equality` `copyWith` `Unions` `Pattern-matching` `Json serializable`,缺點是必須照 freezed 的格式去定義 class, 再跑 code generation command, 但利大於弊 ### Functional Error Handling 把隱式的 try/catch 方式變為顯式的 `Either<L, R>` type, 讓呼叫端意識到並處理 failure cases ```dart Future<String> getUserInfo() async { try { final url = Uri.https(DOMAIN, '/userinfo'); final response = await http.get(url); if (response.statusCode == HttpStatus.ok) { return 'Success'; } else { throw Exception('Failed to get user details'); } } catch (e) { return 'Unknown error ${e.runtimeType}'; } } ``` ```dart Future<Either<Failure, User>> getUserInfo() async { return errorHandler( () async { final url = Uri.https('jsonplaceholder.typicode.com', '/users/1'); final http.Response response = await http.get(url); if (response.statusCode == 200) { return User.fromJson(jsonDecode(response.body)); } else { throw const UserInfoException( 'It seems that the server is not reachable at the moment, try ' 'again later, should the issue persist please reach out to the ' 'developer at a@b.com', ); } }, ); } Future<Either<Failure, T>> errorHandler<T>(AsyncCallBack<T> callback) async { try { return Right(await callback()); } on TimeoutException catch (e) { return Left( Failure(e.message ?? 'Timeout Error!', 'Timeout Error'), ); } on FormatException catch (e) { return Left( Failure(e.message, 'Formatting Error!'), ); } on SocketException catch (e) { return Left( Failure(e.message, 'No Connection!'), ); } on UserInfoException catch (e) { return Left( Failure(e.message, 'User Cannot be found!'), ); } catch (e) { return Left(Failure('Unknown error ${e.runtimeType}', '${e.runtimeType}')); } } Either<Failure, User>? data; @override void initState() { super.initState(); data = getUserInfo(); } @override Widget build(BuildContext context) { if (data != null) { return data!.fold<Widget>( (failure) { return Center(child: Text('Error! ${failure.message}')); }, (success) { return Center(child: Text('Hello ${success.name}')); }, ); } return const Center(child: CircularProgressIndicator.adaptive()); } ``` > [fpdart](https://pub.dev/packages/fpdart) 提供 functional programming 的各種型別,不用自己去寫 `Either` 型別 > 延伸閱讀 [Handling errors in Flutter](https://dartpad.dev/workshops.html?webserver=https://handling-errors-gracefully.web.app#Step1) ### State Management Flutter 的狀態管理工具有很多 (Provider, BLoC, Riverpod, Redux, ... etc), 這裡使用的 [Riverpod](https://docs-v2.riverpod.dev/docs/introduction), 與原 Provider 同作者開發,是為了解決 Provider 的一些痛點而生的 與其說 Riverpod 是一個狀態管理工具,但其實它能做的事情遠不止狀態管理,官方定義為 `A Reactive caching and data-binding framework` , 包含: - catch programming errors at compile-time rather than at runtime - easily fetch, cache, and update data from a remote source - perform reactive caching and easily update your UI - depend on asynchronous or computed state - create, use, and combine providers with minimal boilerplate code - dispose the state of a provider when it is no longer used - write testable code and keep your logic outside the widget tree 前面提到一個 feature 是用不同層去堆疊起來的,而層跟層之間則會透過 Riverpod 來串連起來 Riverpod 提供了八種 Providers, 詳細介紹 / 使用方式可參考[官方文件](https://docs-v2.riverpod.dev/docs/concepts/providers)或[教學文章](https://codewithandrea.com/articles/flutter-state-management-riverpod/) 1. Provider 2. StateProvider (legacy) 3. StateNotifierProvider (legacy) 4. FutureProvider 5. StreamProvider 6. ChangeNotifierProvider (legacy) 7. NotifierProvider (new in Riverpod 2.0) 8. AsyncNotifierProvider (new in Riverpod 2.0) > 資料可以在 Provider 與 Provider 之間互動、處理邏輯、再 cache 起來 ```dart final todosProvider = StateProvider<List<Todo>>((ref) => []); final filterProvider = StateProvider<Filter>((ref) => Filter.all); final filteredTodosProvider = Provider<List<Todo>>((ref) { final todos = ref.watch(todosProvider); switch (ref.watch(filterProvider)) { case Filter.all: return todos; case Filter.completed: return todos.where((todo) => todo.completed).toList(); case Filter.uncompleted: return todos.where((todo) => !todo.completed).toList(); } }); ``` ### Routing 使用 [GoRouter](https://pub.dev/packages/go_router) 來做 Routing, 是一個 Flutter 工程師基於 Navigation 2.0 API 來開發的宣告式 routing 套件,用起來直覺、簡單。 GoRouter 提供 `go` 和 `push` 兩種方式來做頁面導航 - **go**:在同層級中使用 go 做頁面跳轉時,會去修改 navigation stack, 原 (同層) 頁面會從 stack 中去除 - **push**: 目的地 route 總是會被堆疊在當前的 stack 上 ```dart GoRouter( initialLocation: '/', routes: [ // top-level route GoRoute( path: '/', builder: (context, state) => const HomeScreen(), routes: [ // one sub-route GoRoute( path: 'detail', builder: (context, state) => const DetailScreen(), ), // another sub-route GoRoute( path: 'modal', pageBuilder: (context, state) => const MaterialPage( fullscreenDialog: true, child: ModalScreen(), ), ) ], ), ], ) ``` ### Testing - `test` 與 `lib` 同層, 架構上就是與 lib 呈鏡像關係,並在對應的檔名以 `_test.dart` 後綴結尾 - Sample code (TBA) [Unit test](https://docs.flutter.dev/cookbook/testing/unit/introduction) [Widget test](https://docs.flutter.dev/cookbook/testing/widget/introduction) [Riverpod testing guide](https://docs-v2.riverpod.dev/docs/cookbooks/testing) ### Localization This project relies on [flutter_localizations](https://api.flutter.dev/flutter/flutter_localizations/flutter_localizations-library.html) and follows the [official internationalization guide for Flutter](https://flutter.dev/docs/development/accessibility-and-localization/internationalization) #### Adding Strings 1. To add a new localizable string, open the `app_en.arb` file at `lib/l10n/arb/app_en.arb`. ```arb { "@@locale": "en", "account": "Account", "@account": { "description": "Text shown in the title of account text field" }, } ``` 2. Then add a new key/value and description ```arb { "@@locale": "en", "account": "Account", "@account": { "description": "Text shown in the title of account text field" }, "helloWorld": "Hello World", "@helloWorld": { "description": "Hello World Text" } } ``` 3. Use the new string ```dart import 'package:flutterprint/l10n/l10n.dart'; @override Widget build(BuildContext context) { final l10n = context.l10n; return Text(l10n.helloWorld); } ``` ### Adding Supported Locales Update the `CFBundleLocalizations` array in the `Info.plist` at `ios/Runner/Info.plist` to include the new locale. ```xml ... <key>CFBundleLocalizations</key> <array> <string>en</string> <string>zh</string> </array> ... ``` ### Adding Translations 1. For each supported locale, add a new ARB file in `lib/l10n/arb`. ``` ├── l10n │ ├── arb │ │ ├── app_en.arb │ │ └── app_zh.arb ``` 2. Add the translated strings to each `.arb` file: `app_en.arb` ```arb { "@@locale": "en", "account": "Account", "@account": { "description": "Text shown in the title of account text field" }, } ``` `app_zh.arb` ```arb { "@@locale": "zh", "account": "帳號", "@account": { "description": "用於帳號欄位的標題文字" }, } ``` ### Mason package (Templates generator) [mason](https://pub.dev/packages/mason) 是用 Dart 開發的套件,但可生成的 templates 不受程式語言限制,使用方法也算簡單,大致如下: 在 mason 裡樣板叫做 bricks, 首先在專案下執行 mason init 後,會產生 mason.yaml ,用來註冊這個 local (or global) 環境下可以使用的 bricks, 註冊的 bricks 可以來自 `local path` or `git url path` 使用 `mason new` 來創建一個新的 brick, 比如輸入 `mason new foobar_brick` 來產生一個叫做 foobar_brick 的樣板,創建好後資料結構如下,其中 - `__brick__` 裡用來存放這個 brick 會產生的樣板 - `brick.yaml` 則是用來定義樣板的變數細節等等 ``` . ├── mason-lock.json ├── mason.yaml └── foobar_brick ├── __brick__ │ └── HELLO.md ├── brick.yaml ├── CHANGELOG.md ├── LICENSE └── README.md ``` brick 使用 [Mustache 語法](https://mustache.github.io/mustache.5.html) 來定義變數或是條件,如果定義一個變數為 filename_prefix 用來定義檔名前綴,使用 snakeCase, 則可用 mustache 語法包裝成 {{filename_prefix.snakeCase()}} ``` . ├── mason-lock.json ├── mason.yaml └── foobar_brick ├── __brick__ │ └── {{filename_prefix.snakeCase()}}_foobar.dart ├── brick.yaml ├── CHANGELOG.md ├── LICENSE └── README.md ``` 在這個檔案下想生成一個可自定義 class name 的 class 如下,變數是 class_name, 且你希望是 pascalCase, 使用 mustache 語法包裝成 {{class_name.pascalCase()}} ``` class {{class_name.pascalCase()}} { ... } ``` 在 `brick.yaml` 下定義變數的細節,prompt 是在使用 mason_cli 產生這個樣板時若沒指定變數時會詢問 user 的提示,若沒輸入,則會套用 default ``` vars: filename_prefix: type: string description: The filename prefix default: hello_world prompt: What is your filename prefix? class_name: type: string description: The class name default: Foo prompt: What is your class name? ``` 最後在 `mason.yaml` 中註冊這個 brick - `mason add foobar_brick --path {path_to_brick}` 就可以在當下的 local 環境下使用 - `mason make foobar_brick ` 來產生 `foobar_brick` 樣版,上述示範最基本的變數定義,其他還有條件式變數,Nested templates, 或是在產生 brick 前或後透過 hooks 執行腳本 (目前只支援 Dart 寫腳本) 也都能做到 ### CI/CD (WIP) `.gitlab-ci.yml` example ```yaml stages: - test_coverage - prepare - build - internal_deployment - productive_deployment workflow: rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' when: never - when: always test_coverage: stage: test_coverage before_script: - flutter clean - flutter pub get - dart format . --set-exit-if-changed - flutter analyze - flutter pub run build_runner build --delete-conflicting-outputs - flutter --version script: - sh scripts/import_files_coverage.sh homecare - flutter test --coverage ./lib - sh scripts/remove_gen_files_in_lcov.sh - genhtml -o coverage coverage/lcov.info artifacts: expire_in: 2 days paths: - coverage coverage: '/^lines\.+: |\d+\.?\d+\%/' tags: - flutter rules: - if: '$CI_COMMIT_BRANCH == "release"' when: never - when: always build_android: stage: build before_script: - flutter clean - flutter pub get - flutter doctor -v script: - flutter build apk - flutter build appbundle needs: - job: test_coverage artifacts: false - job: setup_android_keys artifacts: true artifacts: expire_in: 2 days paths: - build/app/outputs/apk/release/app-release.apk - build/app/outputs/bundle/release/app-release.aab tags: - flutter rules: - if: '$CI_COMMIT_BRANCH == "develop"' - if: '$CI_COMMIT_BRANCH == "demo"' build_ios_debug: stage: build before_script: - whoami - flutter clean - flutter pub get - flutter doctor -v script: - flutter build ipa --export-options-plist=ExportOptions.plist needs: - job: test_coverage artifacts: false artifacts: expire_in: 2 days paths: - build/ios/ipa/*.ipa tags: - flutter rules: - if: '$CI_COMMIT_BRANCH == "develop"' build_ios_release: stage: build before_script: - whoami - flutter clean - flutter pub get - flutter doctor -v script: - flutter build ipa --export-options-plist=ExportOptions-release.plist needs: - job: test_coverage artifacts: false artifacts: expire_in: 2 days paths: - build/ios/ipa/*.ipa tags: - flutter rules: - if: '$CI_COMMIT_BRANCH == "demo"' setup_android_keys: stage: prepare script: - echo $KEY_STORE | base64 -d > android/release-key.keystore - echo "storeFile=../release-key.keystore" > android/key.properties - echo "storePassword=$STORE_PASSWORD" >> android/key.properties - echo "keyAlias=$KEY_ALIAS" >> android/key.properties - echo "keyPassword=$KEY_PASSWORD" >> android/key.properties tags: - flutter artifacts: paths: - android/release-key.keystore - android/key.properties expire_in: 20 mins rules: - if: '$CI_COMMIT_BRANCH == "develop"' - if: '$CI_COMMIT_BRANCH == "demo"' android_internal_deployment: stage: internal_deployment before_script: - cd android/ - gem install --user-install bundler - bundle config set --local path 'vendor/bundle' - bundle install - bundle update script: - bundle exec fastlane internal needs: - job: build_android tags: - flutter rules: - if: '$CI_COMMIT_BRANCH == "demo"' when: manual ios_testflight_deployment: stage: internal_deployment before_script: - cd ios/ - gem install --user-install bundler - bundle config set --local path 'vendor/bundle' - bundle install - bundle update script: - bundle exec fastlane beta needs: - job: build_ios_release tags: - flutter rules: - if: '$CI_COMMIT_BRANCH == "demo"' when: manual app_store_submit_to_review: stage: productive_deployment before_script: - cd ios/ - gem install --user-install bundler - bundle config set --local path 'vendor/bundle' - bundle install - bundle update script: bundle exec fastlane submit_review tags: - flutter rules: - if: '$CI_COMMIT_BRANCH == "release"' when: manual android_play_store_productive_deployment: stage: productive_deployment before_script: - cd android/ - gem install --user-install bundler - bundle config set --local path 'vendor/bundle' - bundle install - bundle update script: bundle exec fastlane promote_internal_to_production tags: - flutter rules: - if: '$CI_COMMIT_BRANCH == "release"' when: manual ```