Flutter Quick Actions 筆記
今天來聊聊 Flutter 的 `quick_actions`。這東西…很多人都在用,就是長按 App icon 跳出來的快捷鍵。很方便。
但 code 很容易寫爛。一堆 if-else 或 switch-case 塞在同一個地方。不好維護。
這篇是整理一個比較乾淨的架構。一個…嗯,筆記。
重點一句話
老實說,就是用 Command Pattern。把每個 action 都包成獨立的 class,不要寫在一起。
為什麼不要只用 if-else?
一開始可能很快,兩個 action,`if (action == 'home')`... `else if (action == 'settings')`... 搞定。
但之後呢?加第三個、第四個...那個 `if` 區塊會變超長。每次要加新功能,就要去動那個最核心的判斷邏輯。很危險。
說真的,我自己是覺得,架構的目的就是為了讓未來的修改「更安全」、「更獨立」。改 A 不會動到 B。
這個想法,可以看一下 Android Developers 官方對 App Shortcuts 的文件,他們雖然沒規定你 code 怎麼寫,但設計理念就是每個捷徑都是一個獨立的意圖(intent)。我們的 code 也應該反映這點。在 iOS 上,雖然叫法是 `ShortcutItem`,但概念差不多。
實作指引
好,所以整個架構怎麼拆。大概分三塊東西:Client、Action、Navigator。
1. QuickActionsClient:總管
這東西是老大。負責跟 `quick_actions` 套件直接溝通。初始化、設定要顯示哪些按鈕、清掉按鈕,都他做。
這裡面有個小技巧,但超級重要。
abstract interface class QuickActionsClient {
factory QuickActionsClient.instance() => _QuickActionsClientImpl();
Future<void> init();
void checkAndNavigate();
Future<void> setShortcutItems(/* ... */);
Future<void> clearShortcutItems();
}
final class _QuickActionsClientImpl implements QuickActionsClient {
final QuickActions _quickActions = const QuickActions();
late DateTime initTime;
late DateTime actionTime;
QuickActionClientType? lastAction;
QuickActionClientType? initAction;
final threshold = 2; // Seconds
@override
Future<void> init() async {
initTime = DateTime.now();
await _quickActions.initialize((String newActionStr) {
// ... action handling logic ...
final newAction = QuickActionClientType.fromString(newActionStr);
if (newAction != null) {
actionTime = DateTime.now();
lastAction = newAction;
_checkLastAction(newAction);
}
});
}
void _checkLastAction(QuickActionClientType newAction) {
final diff = actionTime.difference(initTime).inSeconds;
if (diff < threshold) {
// App 是因為點擊 quick action 而啟動的
initAction = newAction;
} else {
// App 已經在前景,使用者點了 quick action
PlatformQuickActionNavigator.instance.execute(newAction, isInitial: false);
}
}
@override
void checkAndNavigate() {
// 這個在 App 啟動後才呼叫
if (initAction != null) {
PlatformQuickActionNavigator.instance.execute(initAction!, isInitial: true);
initAction = null; // 用完就清掉
}
}
@override
Future<void> setShortcutItems(/* ... */) async {
await _quickActions.setShortcutItems(
<ShortcutItem>[
ShortcutItem(
type: QuickActionClientType.home.name,
localizedTitle: '首頁',
icon: 'quick_actions_icon',
),
ShortcutItem(
type: QuickActionClientType.settings.name,
localizedTitle: '設定',
icon: 'quick_actions_icon',
),
],
);
}
// ... clearShortcutItems() ...
}
</void></void></void></void></void>
看到了嗎?`_checkLastAction` 裡面那個時間差判斷。用 `initTime` 和 `actionTime` 的秒差,去猜這次的 action 是不是「跟著 App 一起啟動」的。
這很關鍵。因為「啟動 App 並跳轉」跟「在已開啟的 App 內跳轉」的處理方式,有時候會不一樣。例如,前者可能需要等某些服務都初始化完畢才能跳,後者可以直接跳。
2. IPlatformQuickAction:指令本人
這是一個 interface,一個規格。所有 Quick Action 的行為都要遵照這個規格來做。
這就是 Command Pattern 的核心。每個指令都是一個物件。
abstract interface class IPlatformQuickAction {
// 判斷這個指令是不是我要處理的 type
bool isSatisfied(QuickActionClientType type);
// 執行具體動作
Future<void> execute({required bool isInitial});
}
</void>
然後我們就為 `home` 和 `settings` 各做一個 class。
// 前往首頁的 Action
final class HomePlatformQuickAction implements IPlatformQuickAction {
@override
bool isSatisfied(QuickActionClientType type) => type == QuickActionClientType.home;
@override
Future<void> execute({required bool isInitial}) async {
// 這裡就是你的跳轉邏輯
unawaited(App.router.push(const HomeGuardRoute()));
}
}
// 前往設定頁的 Action
final class SettingsPlatformQuickAction implements IPlatformQuickAction {
@override
bool isSatisfied(QuickActionClientType type) => type == QuickActionClientType.settings;
@override
Future<void> execute({required bool isInitial}) async {
unawaited(App.router.push(const SettingsGuardRoute()));
}
}
</void></void>
很乾淨。一個檔案只做一件事。要加第三個 `Profile` action?就再新增一個 `profile_action.dart` 檔案。完全不用改到舊的 code。
3. PlatformQuickActionNavigator:交通警察
最後是這個 Navigator。他手上有一份清單,記載了所有我們做好的 Action(`HomePlatformQuickAction`、`SettingsPlatformQuickAction` 等等)。
他的工作很單純:當 `Client` 跟他說「嘿,有個 `settings` action 進來了」,他就去清單裡一個一個問:「這是你要處理的嗎?」 (`isSatisfied`),直到找到對的人,然後叫他去 `execute`。
final class PlatformQuickActionNavigator {
PlatformQuickActionNavigator._();
static final PlatformQuickActionNavigator instance = PlatformQuickActionNavigator._();
// 把所有做好的 Action 都註冊在這裡
final items = <IPlatformQuickAction>[
HomePlatformQuickAction(),
SettingsPlatformQuickAction(),
];
Future<void> execute(QuickActionClientType type, {required bool isInitial}) async {
// 找到對應的 action 然後執行它
await items.firstWhereOrNull((element) => element.isSatisfied(type))?.execute(isInitial: isInitial);
}
}
</void>
兩種作法的比較
所以...繞這麼一大圈,跟直接用 `switch-case` 到底差在哪?
| 評估項目 | 傳統 Switch-Case | Command Pattern 架構 |
|---|---|---|
| 擴充性 | 差。每次加新 action 都要動到那個超大的 switch block。 | 好。新增一個 action 檔就好,完全不碰舊 code。符合開閉原則。 |
| 可讀性 | 糟。所有邏輯混在一起,當 action 一多,根本是災難。 | 優。一個檔案只負責一件事,非常清楚。檔名就說明了一切。 |
| 職責分離 | 沒有分離。一個巨大的函式包辦了所有事。 | 非常清楚。Client 管監聽,Navigator 管派發,Action 管執行。 |
| 測試 | 難。很難針對單一 action 寫單元測試。 | 簡單。每個 Action class 都可以獨立拿出來測試。 |
常見錯誤與修正
我自己是覺得,這個架構最容易出錯的地方,是導航 (Navigation)。
特別是 `isInitial: true` 的時候,也就是 App 因為 quick action 才啟動。這時候你的 `BuildContext` 可能還沒準備好,或是 `Navigator` 還不能用。
如果在 `execute` 裡面直接 `Navigator.of(context).push(...)`,很可能會 crash。
解決方法... 嗯,一種是確保你的 `App.router` (或任何你用的路由工具) 是全局的 (global),不需要 `context` 就能跳轉。另一種方法是把 `initAction` 存起來,等到 App 的主頁面 `didChangeDependencies` 或類似的生命週期被觸發時,再檢查並執行這個被存起來的 action。原文的 `checkAndNavigate()` 就是在做這件事。
結語
差不多是這樣。這個模式不是什麼新發明,就是設計模式的應用而已。但用在 Quick Actions 上,真的蠻酷的,可以讓 code 變得非常乾淨、好管理。
重點不在 `class` 叫什麼名字,而是那個「拆分」的精神。
你還在用 if-else 或 switch-case 管理你的 Quick Actions 嗎?還是你有更乾淨的作法?留言分享一下你的思路吧。
