Files
bot-rates/android/DEVLOG.md

11 KiB
Raw Permalink Blame History

Android 版開發過程

記錄這個專案從 Rust 桌面版延伸到 Android 平板的完整過程、踩到的坑、以及為什麼做出這些選擇。主線需求:不想裝 Android Studio2GB+ 太肥),全程 CLI 完成。

階段 0決策與選型

候選方案比較

方案 工具鏈重量 重寫成本 體驗 結論
Rust + cargo-apk(沿用現有 main.rs SDK + NDK egui 觸控差、IME 不順
Kotlin + Jetpack ComposeCLI 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 工具:

  1. JDK 17brew install openjdk@17
  2. Android SDKcmdline-tools + platform-tools + platforms;android-34 + build-tools;34.0.0,用 sdkmanager 裝)
  3. Gradlebrew install gradle

IDE 提供的 Compose live preview / refactor / debugger 在這個小專案用不上。每次 ./gradlew installDebug 直接在實機看結果反而更真實。

階段 1專案骨架

手工建 Gradle 多模組專案(實際只一個模組 :app),關鍵檔案:

android/
├── settings.gradle.kts          # 宣告 :app module + pluginManagement repos
├── build.gradle.kts             # 宣告 pluginsAGP / 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 = 26Android 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 >= 14cols[0] 幣別代碼、cols[13] 即期賣出、過濾 TARGETS。parseAmountformatTwd 也是直接翻譯。

版面取捨

egui 版是橫排 [幣別+匯率] [輸入] → [TWD] [複製]。平板雖然寬,但 Compose 版改成兩列:第一列 [幣別+匯率] [輸入框 fillMaxWidth],第二列 [TWD 靠左 weight(1)] [複製鈕]。理由是輸入框能吃滿寬度,手指點選體驗好,且日後也能直接塞進手機直式螢幕不用改版。

階段 3工具鏈安裝與首次 build

這台 Mac 已經裝好的:

  • JDK 17Temurin
  • ~/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未簽章

階段 4Samsung Tab A 實機部署USB 偵錯坑)

機型 SM-T510Galaxy Tab A 10.1

症狀演進

$ adb devices
List of devices attached
        # 空

接著各種症狀:

  1. system_profiler SPUSBDataType 看不到 Android 裝置 → 確定是連線層
  2. 換線後 adb devices 出現 R52N60RZLNW unauthorized
  3. 平板螢幕不跳授權對話框
  4. 設定 → 開發人員選項 → 撤銷 USB 偵錯授權 → 拔插 → 還是不跳
  5. 關掉 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 成功。

階段 5Release 簽章

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 = falseAGP 還是做了一些預設處理)。

驗證簽章:

~/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 簽章 = 現代 Android7.0+)官方推薦格式。

階段 6Gitea Release

Tag + push

git tag -a v1.0 -m "v1.0 首次 release"
git push --tags

用 Gitea API 建 release

Gitea 的 release API 分兩步:

  1. POST /api/v1/repos/{owner}/{repo}/releases 建 release object帶 tag name
  2. 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 反查 idbody 短所以沒這問題。或 curl --data-binary 傳 body 時讓 server 自己處理 escape。

成果總覽

項目 數值
新增原始碼 ~230 行 KotlinMainActivity.kt
新增設定檔 ~9 個 Gradle / Manifest / XML
工具鏈總大小 JDK 17~300MB+ Android SDK~1GB已有+ Gradle~200MB
Debug APK 8.7 MB
Release APK 6.5 MBv2 簽章)
實機驗證 Samsung Galaxy Tab A 10.1SM-T510Android 11
首次冷啟動 < 1 秒
抓匯率耗時 < 500ms
Gitea release v1.0 已上傳 APK

如果要擴充

  • 相容 minSdk 21Android 5.0compileOptionsisCoreLibraryDesugaringEnabled = truedependencies 加 coreLibraryDesugaring,讓 java.time 等新 API 降級。
  • Play Store 上架Play Store 要 AABAndroid App Bundle不是 APK./gradlew bundleRelease,輸出 .aab。另外 Play 會強制要求 targetSdk 跟上最新(目前 34
  • CI 簽章:把 keystore.properties 的四個欄位改成讀 env varSystem.getenv()keystore 本身用 base64 塞進 GitHub Actions secret 再解碼還原。
  • Wear OS / Android TVAndroidManifest.xml<uses-feature> 宣告並支援觸控缺席情境,但這 app 核心互動是輸入金額TV 遙控器體驗會很差,不建議。