# 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

- 該怎麼定義 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
```