iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Build on Google AI

30 天用 Google AI 打造台灣防災速報 App系列 第 18 篇

Day 18|不寫 Kotlin 也不寫 Swift:Flutter 同一套程式碼做出 Android 與 iOS App

  • 分享至 

  • xImage
  •  

系列: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 最需要的時候,往往是網路不穩的時候,所以移除這個套件,改成:

  • 英文字型打包進 App:Plus Jakarta Sans(約 172 KiB)與 Atkinson Hyperlegible Next(約 112 KiB),從 Google Fonts 的 GitHub 下載,授權是 OFL(SIL Open Font License,允許免費使用、可以隨軟體一起散布)。字型檔放在 assets/fonts/,再登記到 pubspec.yaml:
flutter:
  fonts:
    - family: PlusJakartaSans
      fonts:
        - asset: assets/fonts/PlusJakartaSans-Variable.ttf
    - family: AtkinsonHyperlegibleNext
      fonts:
        - asset: assets/fonts/AtkinsonHyperlegibleNext-Variable.ttf
  • 中日韓文字用手機的系統字型,不打包,App 不會因此變大。
  • 網頁版本來就要連網,由 Flutter 自動補下載中文字型。

網頁版有一個小代價:第一次開啟的一兩秒內,比較少用的字(例如隧、鯉、潭、洩)會先顯示成方框,字型下載完才正常。

第一次在 Android 模擬器上執行

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 指令,直接指定要開啟的畫面)就正常了。

Android 模擬器上的英文首頁

這張是 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

iOS 的專案檔用 flutter create --platforms ios --org tw.watchtower . 加進同一個專案,之後把 bundle identifier 改成 tw.watchtower.app。在 iOS 模擬器上執行前,遇到三件事:

  1. Xcode 要先完成首次安裝元件(sudo xcodebuild -runFirstLaunch)。
  2. 本機端只有 iOS SDK,沒有模擬器的執行環境,要用 xcodebuild -downloadPlatform iOS 從 Apple 下載,約 8 到 10 GB。
  3. 第一次建置時,Xcode 透過 Swift Package Manager(Apple 的套件管理工具)從 GitHub 下載 Firebase 的 iOS 套件。專案的 ios/ 目錄下沒有 Podfile,所以沒有用到前面 flutter doctor 提到的 CocoaPods。

iOS 在 Firebase 註冊成 App 之後,要做三件事:

  • 下載設定檔 GoogleService-Info.plist,加進 Xcode 專案的 Runner target(iOS App 本體的建置目標),只放進資料夾不會被打包。
  • 在 Flutter 的 Firebase 設定檔 firebase_options.dart 加上 iOS 的設定,資料讀取改走 Firestore 套件,和 Android、網頁版相同。
  • App Check(Day 15)在 iOS 的正式版用 Apple 的 App Attest 證明請求來自真的 App;模擬器上的除錯版用 debug provider,要把它印出的權杖登錄到 Firebase 主控台才能通過驗證。

iOS 模擬器上的英文首頁

這張是 10/1 在 iPhone 模擬器上拍的英文首頁,和上面 Android 那張來自同一個 Flutter 專案,版面與配色相同。兩張拍攝的時間不同,這張拍攝時 Firestore 上有 60 則生效中的示警。

今日小結

  • Flutter 用同一套 Dart 程式碼建置 Android、iOS 與網頁版;flutter doctor 列出各平台缺少的工具。
  • 畫面只依賴資料介面,資料來源從 REST 換成即時監聽時,畫面不用改。
  • App 的識別碼在建立專案時就要決定,事後改名要重新註冊 Firebase、重設金鑰限制。
  • iOS 模擬器要另外下載執行環境;Firebase 的 iOS 套件由 Xcode 透過 Swift Package Manager 取得,設定檔要加進 Runner target。
  • 介面文字放 ARB 檔、四種語言;分類與發布單位用固定對照表,不交給 AI。
  • 英文字型打包進 App,中日韓文字用系統字型,斷網也能正常顯示。
  • 第一次建置 Android 花了 2,463 秒,主要在下載 Gradle;第二次 3.6 秒。
  • 同一套程式碼仍有平台差異:問答只在 Android 轉型失敗,滾輪只在網頁版不能用滑鼠拖曳。

明天 Day 19:Firestore 的示警一更新,App 的畫面就跟著變。


上一篇
Day 17|從一段英文描述開始做出 22 張畫面:用 Google Stitch 設計 App,以及要人工檢查的地方
系列文
30 天用 Google AI 打造台灣防災速報 App 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言