11 KiB
Android 版開發過程
記錄這個專案從 Rust 桌面版延伸到 Android 平板的完整過程、踩到的坑、以及為什麼做出這些選擇。主線需求:不想裝 Android Studio(2GB+ 太肥),全程 CLI 完成。
階段 0:決策與選型
候選方案比較
| 方案 | 工具鏈重量 | 重寫成本 | 體驗 | 結論 |
|---|---|---|---|---|
Rust + cargo-apk(沿用現有 main.rs) |
中(SDK + NDK) | 低 | egui 觸控差、IME 不順 | ✗ |
| Kotlin + Jetpack Compose(CLI) | 中(SDK 300MB) | 低(核心邏輯 ~50 行) | 原生最佳 | ✓ |
| Flutter / Tauri Mobile | 重(再引入一整個生態) | 中 | 不差 | overkill |
| PWA / 網頁版 | 零 | 低 | 差;台銀 CSV 沒開 CORS 直接擋掉 | ✗ |
選 Kotlin + Compose,因為邏輯極簡(抓 CSV → parse col 13 → 乘法),Compose 的宣告式 UI 跟 egui 心智模型相近,轉換幾乎是 1:1。
「不用 Android Studio」的真正意思
Android Studio 是 IDE,背後其實只靠三個 CLI 工具:
- JDK 17(
brew install openjdk@17) - Android SDK(
cmdline-tools+platform-tools+platforms;android-34+build-tools;34.0.0,用sdkmanager裝) - Gradle(
brew install gradle)
IDE 提供的 Compose live preview / refactor / debugger 在這個小專案用不上。每次 ./gradlew installDebug 直接在實機看結果反而更真實。
階段 1:專案骨架
手工建 Gradle 多模組專案(實際只一個模組 :app),關鍵檔案:
android/
├── settings.gradle.kts # 宣告 :app module + pluginManagement repos
├── build.gradle.kts # 宣告 plugins(AGP / Kotlin / Compose compiler)
├── gradle.properties # JVM args / AndroidX / nonTransitiveRClass
├── app/
│ ├── build.gradle.kts # compileSdk 34, minSdk 26, Compose BOM
│ └── src/main/
│ ├── AndroidManifest.xml
│ ├── res/values/strings.xml
│ └── java/tw/local/botrates/MainActivity.kt
版本鎖定
AGP 8.5.2 + Kotlin 2.0.20 + Compose BOM 2024.09.02
Kotlin 2.0 開始 Compose compiler 改走獨立 Gradle plugin org.jetbrains.kotlin.plugin.compose,不再需要寫 composeOptions { kotlinCompilerExtensionVersion = "..." }。
minSdk = 26(Android 8.0+)
26 以下要額外處理 java.time 降級 / desugaring,且目前 Android 裝置 8.0 以下普及率已很低,不值得為此複雜化設定。
階段 2:把 Rust 邏輯 port 成 Kotlin
核心檔案 MainActivity.kt(~230 行)。對照 Rust 版的轉換:
| Rust (egui) | Kotlin (Compose) |
|---|---|
ureq::AgentBuilder |
HttpURLConnection(標準函式庫夠用,不引 OkHttp) |
thread::spawn + mpsc::channel |
rememberCoroutineScope() + withContext(Dispatchers.IO) |
App struct + impl eframe::App |
@Composable fun App() + remember { mutableStateOf(...) } |
HashMap<String, String> 存輸入 |
mutableStateMapOf<String, String> |
| `ui.output_mut( | o |
setup_fonts 執行期讀 OS 字型 |
免;Android 有 CJK 內建 |
CSV 解析邏輯一模一樣:cols.size >= 14、cols[0] 幣別代碼、cols[13] 即期賣出、過濾 TARGETS。parseAmount 和 formatTwd 也是直接翻譯。
版面取捨
egui 版是橫排 [幣別+匯率] [輸入] → [TWD] [複製]。平板雖然寬,但 Compose 版改成兩列:第一列 [幣別+匯率] [輸入框 fillMaxWidth],第二列 [TWD 靠左 weight(1)] [複製鈕]。理由是輸入框能吃滿寬度,手指點選體驗好,且日後也能直接塞進手機直式螢幕不用改版。
階段 3:工具鏈安裝與首次 build
這台 Mac 已經裝好的:
- JDK 17(Temurin)
~/Library/Android/sdk(含 platforms android-34、build-tools 34.0.0、platform-tools、cmdline-tools)
只缺 Gradle:
brew install gradle # 裝到 9.4.1
local.properties 指向 SDK
sdk.dir=/Users/timmy/Library/Android/sdk
此檔案 gitignored,每個開發者自己的 SDK 路徑不同。
產 Gradle wrapper(只做一次)
gradle wrapper --gradle-version 8.9 --distribution-type bin
之後所有指令用 ./gradlew,不再依賴系統 gradle。wrapper 檔案進 repo,確保每個 clone 下來的人用同一個 Gradle 版本。
第一次 debug build
./gradlew assembleDebug
耗時 2:13(下載 AGP / Compose 依賴約 300MB,之後增量建置 < 10 秒)。產物:
app/build/outputs/apk/debug/app-debug.apk # 8.7 MB(未簽章)
階段 4:Samsung Tab A 實機部署(USB 偵錯坑)
機型 SM-T510(Galaxy Tab A 10.1)。
症狀演進
$ adb devices
List of devices attached
# 空
接著各種症狀:
system_profiler SPUSBDataType看不到 Android 裝置 → 確定是連線層- 換線後
adb devices出現R52N60RZLNW unauthorized - 平板螢幕不跳授權對話框
- 設定 → 開發人員選項 → 撤銷 USB 偵錯授權 → 拔插 → 還是不跳
- 關掉 USB 偵錯 → 再開 USB 偵錯 → 對話框終於跳出 → 允許 →
device
教訓
Samsung(至少這台的 One UI)的 USB 偵錯授權對話框有時會被卡在某個內部狀態。標準的「撤銷授權 + 拔插」有時沒用,**「關掉 USB 偵錯再重開」**似乎會強制重設授權管理器的狀態。這條排除步驟以後要記住。
另外需要同時滿足:
- 螢幕解鎖且保持亮著(鎖屏狀態下對話框不顯示)
- USB 模式是「檔案傳輸」而非「僅供電」
- 線支援資料傳輸(不是純充電線)
安裝與啟動
adb install -r app/build/outputs/apk/debug/app-debug.apk # Streamed Install, Success
adb shell monkey -p tw.local.botrates -c android.intent.category.LAUNCHER 1
跑起來字型正常(不用 bundle CJK 字型,系統內建 Noto Sans CJK)、匯率正確抓取、複製貼到其他 app 成功。
階段 5:Release 簽章
Android 的 release APK 必須簽章才能安裝(debug 版系統會自動用 debug keystore 簽,release 必須自備)。
產生 keystore
keytool -genkey -v \
-keystore ~/.android/botrates-release.keystore \
-alias botrates \
-keyalg RSA -keysize 2048 -validity 10000 \
-storepass botrates1234 -keypass botrates1234 \
-dname "CN=Timmy, OU=Personal, O=BotRates, L=Taipei, ST=Taipei, C=TW"
關鍵決策:keystore 檔案放 repo 外(~/.android/),密碼寫在 gitignored 的 android/keystore.properties。這樣 repo 本身永遠不含 signing secrets,也方便未來換 CI 時用 env var 改寫。
build.gradle.kts 的條件式 signing config
val keystoreProps = Properties().apply {
val f = rootProject.file("keystore.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
android {
signingConfigs {
if (keystoreProps.isNotEmpty()) {
create("release") {
storeFile = file(keystoreProps.getProperty("storeFile"))
storePassword = keystoreProps.getProperty("storePassword")
keyAlias = keystoreProps.getProperty("keyAlias")
keyPassword = keystoreProps.getProperty("keyPassword")
}
}
}
buildTypes {
release {
if (keystoreProps.isNotEmpty()) {
signingConfig = signingConfigs.getByName("release")
}
}
}
}
沒有 keystore.properties(例如別人 clone repo)時不會 fail,只是 assembleRelease 會產出 unsigned APK 並失敗於安裝階段。這比硬噴 FileNotFoundException 友善。
建置結果
./gradlew assembleRelease # 52s
| 版本 | 大小 |
|---|---|
app-debug.apk |
8.7 MB |
app-release.apk |
6.5 MB |
release 小一截是因為 AGP 對 release build 做了 resource shrinking 和 bytecode optimization(雖然 isMinifyEnabled = false,AGP 還是做了一些預設處理)。
驗證簽章:
~/Library/Android/sdk/build-tools/34.0.0/apksigner verify --verbose app-release.apk
# Verifies
# Verified using v2 scheme (APK Signature Scheme v2): true
v2 簽章 = 現代 Android(7.0+)官方推薦格式。
階段 6:Gitea Release
Tag + push
git tag -a v1.0 -m "v1.0 首次 release"
git push --tags
用 Gitea API 建 release
Gitea 的 release API 分兩步:
POST /api/v1/repos/{owner}/{repo}/releases建 release object(帶 tag name)POST /api/v1/repos/{owner}/{repo}/releases/{id}/assets上傳附件
curl -X POST "http://.../api/v1/repos/timmy/bot-rates/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d '{"tag_name":"v1.0","name":"v1.0 — 首版 Android APK","body":"..."}'
# → 回傳 JSON 含 release.id
curl -X POST "http://.../api/v1/repos/timmy/bot-rates/releases/$ID/assets?name=BotRates-v1.0.apk" \
-H "Authorization: token $TOKEN" \
-F "attachment=@android/app/build/outputs/apk/release/app-release.apk"
踩到小坑
第一次用 python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 解析 curl 回傳時 python JSON decoder 對 body 裡的裸 \n 字元噴 Invalid control character。原因是 release body 裡的 markdown 換行在 HTTP response 是真正的 0x0A,而 Python JSON 規範要求字串值裡的 control char 要 escape。
解法:改用 /releases/tags/{tag} endpoint 反查 id,body 短所以沒這問題。或 curl --data-binary 傳 body 時讓 server 自己處理 escape。
成果總覽
| 項目 | 數值 |
|---|---|
| 新增原始碼 | ~230 行 Kotlin(MainActivity.kt) |
| 新增設定檔 | ~9 個 Gradle / Manifest / XML |
| 工具鏈總大小 | JDK 17(~300MB)+ Android SDK(~1GB,已有)+ Gradle(~200MB) |
| Debug APK | 8.7 MB |
| Release APK | 6.5 MB(v2 簽章) |
| 實機驗證 | Samsung Galaxy Tab A 10.1(SM-T510,Android 11) |
| 首次冷啟動 | < 1 秒 |
| 抓匯率耗時 | < 500ms |
| Gitea release | v1.0 已上傳 APK |
如果要擴充
- 相容 minSdk 21(Android 5.0):
compileOptions加isCoreLibraryDesugaringEnabled = true,dependencies 加coreLibraryDesugaring,讓java.time等新 API 降級。 - Play Store 上架:Play Store 要 AAB(Android App Bundle)不是 APK,改
./gradlew bundleRelease,輸出.aab。另外 Play 會強制要求 targetSdk 跟上最新(目前 34)。 - CI 簽章:把
keystore.properties的四個欄位改成讀 env var(System.getenv()),keystore 本身用 base64 塞進 GitHub Actions secret 再解碼還原。 - Wear OS / Android TV:
AndroidManifest.xml加<uses-feature>宣告並支援觸控缺席情境,但這 app 核心互動是輸入金額,TV 遙控器體驗會很差,不建議。