系列:30 天用 Google AI 打造臺灣防災速報 App(Day 18/30)
後端上線了,接著做 App。Android 原生要寫 Kotlin,iOS 原生要寫 Swift(兩個平台各自的官方開發語言),一個人同時維護兩套程式碼,時間不夠。這個系列用 Flutter:Google 的跨平台 App 框架,用 Dart 語言寫一次,就能建置成 Android、iOS 與網頁版。
今天從 9/25 晚上的環境建置與專案骨架寫起,也補上後續 Android、iOS 的實測結果與平台差異。畫面與資料串接的做法,後面的文章再逐一說明。
用 Homebrew(macOS 的套件管理工具)安裝:
brew install --cask flutter # 155 秒,Flutter 3.47.5、Dart 3.13.4
brew install --cask android-studio # 63 秒
flutter doctor -v # 檢查開發環境
flutter doctor 會逐項檢查各平台需要的工具。第一次的結果(節錄):
[✓] Flutter (Channel stable, 3.47.5, on macOS 26.7.1 25G313 darwin-arm64, locale zh-Hant-TW)
[✗] Android toolchain - develop for Android devices
✗ Unable to locate Android SDK.
[!] Xcode - develop for iOS and macOS (Xcode 27.0)
✗ Xcode requires additional components to be installed in order to run.
! CocoaPods not installed.
[✓] Chrome - develop for the web
Android 缺 SDK(開發套件);iOS 缺 Xcode(Apple 的官方開發工具)的首次安裝元件,也缺 CocoaPods(iOS 的套件管理工具之一,flutter doctor 會檢查有沒有安裝)。只有網頁版可以直接用。所以先從網頁版開始,Android 和 iOS 的工具邊做邊補。
mkdir -p ~/dev/watchtower_app && cd ~/dev/watchtower_app
flutter create --org tw.watchtower --project-name watchtower_app --platforms android,web .
最後的 . 代表建立在目前的資料夾,所以要先建好專案資料夾並切換進去。
--org 決定 App 的識別碼,也就是系統用來辨認 App 的唯一名稱:Android 叫 application ID,iOS 叫 bundle identifier。這次建立後,Android 的 application ID 是 tw.watchtower.watchtower_app;後來加入 iOS 平台時,Flutter 產生的 bundle identifier 是 tw.watchtower.watchtowerApp,兩者寫法不同。--platforms 先只開 Android 與網頁版,iOS 之後再加。
後來我在上架前把兩個平台都改成 tw.watchtower.app。改名的代價比預期大。Firebase 上已註冊的 App 不能改識別碼,只能新增一個 App、重新下載設定檔。App Check 與 API 金鑰的限制也要重設。手機上已安裝的舊版會被當成另一個 App,不會自動升級。識別碼最好在建立專案時就決定好。
畫面做完之後,lib/ 整理成以下結構(flutter create 只會產生 main.dart,其餘是自己建的):
lib/
data/ # 資料來源:示警、地震、問答
screens/ # 各個畫面
widgets/ # 共用元件(地圖、卡片)
l10n/ # 四種語言的文字
theme.dart # 配色、字級、間距
畫面只依賴 data/ 裡定義的介面,不直接碰資料庫。這樣做的好處在當晚就用到了:一開始 App 還沒註冊到 Firebase,先用 Firestore 的 REST API(用一般的網址請求直接讀寫資料庫,不經過 Firebase 套件)讀取公開的示警;註冊完成後換成 Firestore 的即時監聽,只改了 data/,當時不需要修改畫面層的程式。
Flutter 的多語系要先加入兩個套件:flutter_localizations 提供 Flutter 內建元件(例如日期選擇器)的多語文字,intl 處理日期、數字與訊息的格式。
flutter pub add intl 'flutter_localizations:{"sdk":"flutter"}'
接著在 pubspec.yaml 的 flutter: 底下加上 generate: true,並在專案根目錄的 l10n.yaml 指定文字檔的位置:
arb-dir: lib/l10n
template-arb-file: app_zh.arb
output-localization-file: app_localizations.dart
介面上的固定文字放在 ARB 檔(Flutter 的多語系文字格式),每種語言一個檔案。以下是中文檔的節錄,帶參數的字串要另外宣告參數的型別:
{
"@@locale": "zh",
"appTitle": "防災速報",
"activeAlerts": "目前生效中的示警",
"activeCount": "共 {count} 則",
"@activeCount": {
"placeholders": { "count": { "type": "int" } }
},
"noAlerts": "目前沒有生效中的示警"
}
執行 flutter gen-l10n 會依這些檔案產生程式碼。MaterialApp(Flutter App 的最外層元件)要設定 supportedLocales: AppLocalizations.supportedLocales 與 localizationsDelegates,畫面再用 AppLocalizations.of(context).activeAlerts 取得目前語言的文字。App 預設跟隨手機的語言,右上角可以手動切換。日文、韓文還沒有請母語者審核,選單上標「Beta」。
示警的分類(例如「水庫放流」)和發布單位(例如「水利署」)是資料裡的值,ARB 檔管不到,所以另外寫一張固定的對照表。發布單位的英文用各機關官網上的正式名稱。這些值數量有限,翻一次、審一次就固定,不交給 AI 翻譯。示警的內文才由後端用 Gemini 翻譯(Day 13)。
一開始用 google_fonts 套件,字型在 App 執行時才從網路下載。防災 App 最需要的時候,往往是網路不穩的時候,所以移除這個套件,改成:
assets/fonts/,再登記到 pubspec.yaml:flutter:
fonts:
- family: PlusJakartaSans
fonts:
- asset: assets/fonts/PlusJakartaSans-Variable.ttf
- family: AtkinsonHyperlegibleNext
fonts:
- asset: assets/fonts/AtkinsonHyperlegibleNext-Variable.ttf
網頁版有一個小代價:第一次開啟的一兩秒內,比較少用的字(例如隧、鯉、潭、洩)會先顯示成方框,字型下載完才正常。
Android SDK 的基本元件,由 Android Studio 第一次開啟時的設定精靈安裝。精靈把 SDK 裝在預設位置 ~/Library/Android/sdk,包含 platform-tools(提供 adb,Android 的除錯工具)、emulator(模擬器)與 Android 平台檔。Flutter 會自動找到這個預設位置,不需要另外設定;執行 flutter doctor 會列出 SDK 的絕對路徑,也就是上面這個 ~/Library/Android/sdk 展開後的位置。
接著安裝命令列工具與模擬器映像檔。sdkmanager 用來下載 Android SDK 的元件,avdmanager 用來建立虛擬手機。以下指令依建置紀錄整理:
brew install --cask android-commandlinetools # 115 秒
sdkmanager 'cmdline-tools;latest' 'system-images;android-36.1;google_apis;arm64-v8a' # 51 秒
avdmanager create avd -n Watchtower_Pixel -k 'system-images;android-36.1;google_apis;arm64-v8a' -d pixel_9
下載模擬器映像檔之前要接受 Android SDK 的授權條款。
啟動模擬器,並建置第一個 APK(Android 的安裝檔):
emulator -avd Watchtower_Pixel -no-snapshot-save -no-boot-anim # 開機 25 秒,第二次 7 秒
flutter build apk --debug
Running Gradle task 'assembleDebug'... 2461.3s
✓ Built build/app/outputs/flutter-apk/app-debug.apk
第一次建置,Gradle 的工作花了 2,461.3 秒,整個指令 2,463 秒,大部分時間在下載 Gradle(Android 的建置工具,約 235 MB,當時的下載速度約每秒 95 KB)。建置過程中 Gradle 也自動安裝了 Android Platform 35 與 CMake(編譯原生程式用的建置工具)。第二次建置只要 3.6 秒。
安裝到模擬器並啟動。以下是 9/25 改名前的紀錄,所以指令裡是舊的識別碼 tw.watchtower.watchtower_app,改名後要換成 tw.watchtower.app:
adb install -r build/app/outputs/flutter-apk/app-debug.apk
adb shell am start -n tw.watchtower.watchtower_app/.MainActivity
一開始用 adb shell monkey -p tw.watchtower.watchtower_app 1 啟動。monkey 是 Android 內建的隨機操作測試工具,常被借來開啟 App,但這次 App 未啟動,畫面停在桌面;改用 am start(Android 的 Activity Manager 指令,直接指定要開啟的畫面)就正常了。

這張是 9/30 改名後建置的畫面,識別碼已經是 tw.watchtower.app,App 語言從選單切成英文。介面文字來自英文的 ARB 檔,示警內文用的是後端產生的英文摘要。當時 Firestore 上有 50 則生效中的示警。
同一套程式碼可以建置成各平台,但執行時的行為不一定相同。9 月 25 至 26 日遇到兩個只在某個平台出現的問題:
1. 只在 Android 上失敗的問答。 問答呼叫後端的 ask 函式時,原本這樣寫:
// _functions = FirebaseFunctions.instanceFor(region: 'asia-east1')
final res = await _functions
.httpsCallable('ask')
.call<Map<String, dynamic>>({'question': question, 'lang': locale});
網頁版正常,Android 上每次都失敗。原因是 Android 原生端回傳的型別是 Map<Object?, Object?>,指定成 Map<String, dynamic> 會在執行時轉型失敗。這個問題在程式碼審查時被找出來,改成先不指定型別、拿到結果後再轉換:
final res = await _functions.httpsCallable('ask').call({
'question': question,
'lang': locale,
});
final data = Map<String, dynamic>.from(res.data as Map);
2. 只在網頁版失敗的滾輪。 歷史地震地圖用一個滾輪選規模門檻。手機上用手指拖曳正常,網頁版用滑鼠拖曳卻沒有反應。Flutter 的捲動元件預設只接受觸控拖曳,網頁上要另外加入滑鼠與觸控板:
// import 'package:flutter/gestures.dart'; // PointerDeviceKind
ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse, PointerDeviceKind.trackpad},
),
child: picker,
)
兩個問題都只在一個平台出現。用跨平台框架,每個平台仍要實際執行過一次,才找得到這類問題。
iOS 的專案檔用 flutter create --platforms ios --org tw.watchtower . 加進同一個專案,之後把 bundle identifier 改成 tw.watchtower.app。在 iOS 模擬器上執行前,遇到三件事:
sudo xcodebuild -runFirstLaunch)。xcodebuild -downloadPlatform iOS 從 Apple 下載,約 8 到 10 GB。ios/ 目錄下沒有 Podfile,所以沒有用到前面 flutter doctor 提到的 CocoaPods。iOS 在 Firebase 註冊成 App 之後,要做三件事:
GoogleService-Info.plist,加進 Xcode 專案的 Runner target(iOS App 本體的建置目標),只放進資料夾不會被打包。firebase_options.dart 加上 iOS 的設定,資料讀取改走 Firestore 套件,和 Android、網頁版相同。
這張是 10/1 在 iPhone 模擬器上拍的英文首頁,和上面 Android 那張來自同一個 Flutter 專案,版面與配色相同。兩張拍攝的時間不同,這張拍攝時 Firestore 上有 60 則生效中的示警。
flutter doctor 列出各平台缺少的工具。明天 Day 19:Firestore 的示警一更新,App 的畫面就跟著變。