Files
diary-news/docs/android/03-build-run.md
Mavis 02f0260dfc docs(android): 完整方案 + logo 资源 + 启动屏
新增 docs/android/ 目录:
- README.md 总入口(快速上手 + 决策摘要 + 数据流)
- 01-architecture.md 模块划分 + 数据流 + 选型理由
- 02-api-contract.md 每个接口的请求/响应 + DTO 字段映射
- 03-build-run.md Gradle/SDK/网络安全白名单/真机调试
- 04-milestones.md 7 天里程碑 + DoD + E2E 测试场景

新增 assets/:
- logo/: 主图标 master + adaptive icon + 5 DPI launcher (方/圆)
- splash/: 启动屏 logo + 完整背景预览 + 5 DPI 资源
- android_resources/: 集成所需的 XML(adaptive icon/主题/颜色/字符串/drawable/layout)
- INTEGRATION.md 集成指南
- logo.svg + _make_logo.py 设计源

设计风格:参考用户提供的木质方块字母积木图,米色木纹底 +
深棕色字母 D,代表 'Diary',温暖私人日记感。

服务器体检:所有容器/API/DB/翻译主链路正常,TMT 本月已用 0.37%。
MaaS 备用通道上次已验证可用。
2026-06-10 14:11:43 +08:00

17 KiB

03 · 构建与运行

从 0 到能装到真机跑的 APK,每一步都给具体命令。

假设你本地已经有:Android Studio Hedgehog(2023.1.1)+ + JDK 17 + Android SDK 35 + Kotlin 2.0.21


0. 环境清单

工具 版本 验证命令
Android Studio Hedgehog (2023.1.1) 或更新 Help → About
JDK 17 java -version
Android SDK 35 SDK Manager 看 "Android 15.0"
Gradle 8.10+(Studio 自带) gradle --version
Kotlin 2.0.21(项目自带) 无所谓

JDK 注意:JDK 17,不是 11、不是 21。AGP 8.7 要求 JDK 17。


1. 新建工程

  1. File → New → New Project
  2. Empty Activity (Compose)
  3. 配置:
Name Diary News
Package name com.diary.news
Save location ~/projects/diary-news-android/
Language Kotlin
Minimum SDK API 24 ("Nougat")
Build configuration language Kotlin DSL
☑️ Use Version Catalog 勾上(用 libs.versions.toml)
  1. Finish

2. 改版本目录

打开 gradle/libs.versions.toml,整体替换为:

[versions]
agp = "8.7.2"
kotlin = "2.0.21"
ksp = "2.0.21-1.0.27"

# AndroidX & Compose
compose-bom = "2024.10.01"
activity-compose = "1.9.3"
lifecycle = "2.8.7"
navigation-compose = "2.8.4"

# Network
retrofit = "2.11.0"
retrofit-kotlinx-converter = "1.0.0"
okhttp = "4.12.0"
kotlinx-serialization = "1.7.3"
kotlinx-coroutines = "1.9.0"

# DI
hilt = "2.52"
hilt-navigation-compose = "1.2.0"

# Persistence
room = "2.6.1"
security-crypto = "1.1.0-alpha06"

# Paging
paging = "3.3.4"
paging-compose = "3.3.4"

# Image
coil = "2.7.0"

# Test
junit = "4.13.2"
mockk = "1.13.13"
turbine = "1.2.0"

[libraries]
# Core
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }

# Compose
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-material-icons = { module = "androidx.compose.material:material-icons-extended" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" }

# Network
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit-kotlinx-converter" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }

# DI
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-navigation-compose" }

# Persistence
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
room-paging = { module = "androidx.room:room-paging", version.ref = "room" }
security-crypto = { module = "androidx.security:security-crypto-ktx", version.ref = "security-crypto" }

# Paging
paging-runtime = { module = "androidx.paging:paging-runtime-ktx", version.ref = "paging" }
paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging-compose" }

# Image
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }

# Test
junit = { module = "junit:junit", version.ref = "junit" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }

3. 改 app/build.gradle.kts

整体替换为:

plugins {
 alias(libs.plugins.android.application)
 alias(libs.plugins.kotlin.android)
 alias(libs.plugins.kotlin.compose)
 alias(libs.plugins.kotlin.serialization)
 alias(libs.plugins.ksp)
 alias(libs.plugins.hilt)
}

android {
 namespace = "com.diary.news"
 compileSdk = 35

 defaultConfig {
 applicationId = "com.diary.news"
 minSdk = 24
 targetSdk = 35
 versionCode = 1
 versionName = "0.1.0"

 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
 }

 buildTypes {
 debug {
 isMinifyEnabled = false
 }
 release {
 isMinifyEnabled = true
 proguardFiles(
 getDefaultProguardFile("proguard-android-optimize.txt"),
 "proguard-rules.pro",
 )
 // debug 阶段先不上签名,直接装 debug APK
 // 真要 release,见 §6
 }
 }

 compileOptions {
 sourceCompatibility = JavaVersion.VERSION_17
 targetCompatibility = JavaVersion.VERSION_17
 }

 kotlinOptions {
 jvmTarget = "17"
 }

 buildFeatures {
 compose = true
 }

 packaging {
 resources {
 excludes += "/META-INF/{AL2.0,LGPL2.1}"
 }
 }
}

dependencies {
 // Compose BOM 统一管
 implementation(platform(libs.androidx.compose.bom))
 implementation(libs.androidx.activity.compose)
 implementation(libs.androidx.compose.ui)
 implementation(libs.androidx.compose.ui.graphics)
 implementation(libs.androidx.compose.ui.tooling.preview)
 implementation(libs.androidx.compose.material3)
 implementation(libs.androidx.compose.material.icons)
 implementation(libs.androidx.lifecycle.viewmodel.compose)
 implementation(libs.androidx.lifecycle.runtime.compose)
 implementation(libs.androidx.navigation.compose)
 debugImplementation(libs.androidx.compose.ui.tooling)

 // Coroutines
 implementation(libs.kotlinx.coroutines.android)

 // Network
 implementation(libs.retrofit)
 implementation(libs.retrofit.kotlinx.serialization)
 implementation(libs.okhttp)
 implementation(libs.okhttp.logging)

 // Serialization
 implementation(libs.kotlinx.serialization.json)

 // DI
 implementation(libs.hilt.android)
 ksp(libs.hilt.compiler)
 implementation(libs.hilt.navigation.compose)

 // Persistence
 implementation(libs.room.runtime)
 implementation(libs.room.ktx)
 implementation(libs.room.paging)
 ksp(libs.room.compiler)
 implementation(libs.security.crypto)

 // Paging
 implementation(libs.paging.runtime)
 implementation(libs.paging.compose)

 // Image
 implementation(libs.coil.compose)

 // Test
 testImplementation(libs.junit)
 testImplementation(libs.mockk)
 testImplementation(libs.turbine)
 testImplementation(libs.kotlinx.coroutines.test)
}

4. 网络安全白名单(关键!)

4.1 app/src/main/AndroidManifest.xml

整体替换:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

 <uses-permission android:name="android.permission.INTERNET" />
 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

 <application
 android:name=".DiaryNewsApp"
 android:allowBackup="false"
 android:dataExtractionRules="@xml/data_extraction_rules"
 android:fullBackupContent="@xml/backup_rules"
 android:icon="@mipmap/ic_launcher"
 android:label="@string/app_name"
 android:roundIcon="@mipmap/ic_launcher_round"
 android:supportsRtl="true"
 android:theme="@style/Theme.DiaryNews"
 android:networkSecurityConfig="@xml/network_security_config"
 tools:targetApi="35"
 xmlns:tools="http://schemas.android.com/tools">

 <activity
 android:name=".MainActivity"
 android:exported="true"
 android:theme="@style/Theme.DiaryNews">
 <intent-filter>
 <action android:name="android.intent.action.MAIN" />
 <category android:name="android.intent.category.LAUNCHER" />
 </intent-filter>
 </activity>
 </application>
</manifest>

4.2 app/src/main/res/xml/network_security_config.xml

新建文件:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
 <!-- 默认所有域走系统 HTTPS(拒绝明文)-->
 <base-config cleartextTrafficPermitted="false">
 <trust-anchors>
 <certificates src="system" />
 </trust-anchors>
 </base-config>

 <!-- 例外:只对后端 server 放行明文 HTTP -->
 <domain-config cleartextTrafficPermitted="true">
 <domain includeSubdomains="false">207.57.129.228</domain>
 </domain-config>
</network-security-config>

4.3 app/src/main/res/xml/backup_rules.xml(禁止备份 token)

<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
 <exclude domain="sharedpref" path="auth_prefs.xml" />
</full-backup-content>

4.4 app/src/main/res/xml/data_extraction_rules.xml(Android 12+)

<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
 <cloud-backup>
 <exclude domain="sharedpref" path="auth_prefs.xml" />
 </cloud-backup>
 <device-transfer>
 <exclude domain="sharedpref" path="auth_prefs.xml" />
 </device-transfer>
</data-extraction-rules>

5. ProGuard / R8 规则(发布包必加)

app/proguard-rules.pro:

# Retrofit
-keepattributes Signature, InnerClasses, EnclosingMethod
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
-keepattributes AnnotationDefault
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
-keep,allowobfuscation,allowshrinking class retrofit2.Response
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation

# kotlinx.serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt

-keep,includedescriptorclasses class com.diary.news.**$$serializer { *; }
-keepclassmembers class com.diary.news.** {
 *** Companion;
 }
-keepclasseswithmembers class com.diary.news.** {
 kotlinx.serialization.KSerializer serializer(...);
 }

# OkHttp
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**

# Hilt
-keep class dagger.hilt.** { *; }
-keep class * extends dagger.hilt.android.HiltAndroidApp

6. 第一次 Sync & Build

6.1 Sync Gradle

  • Android Studio 顶部会弹"Gradle files have changed..." → Sync Now
  • 或菜单 File → Sync Project with Gradle Files
  • 等 5-15 分钟(下载依赖)

6.2 第一次 build

# 命令行
./gradlew assembleDebug

# 或 Studio 右上角 ▶️ 直接 Run

预期:

  • BUILD SUCCESSFUL
  • 产物在 app/build/outputs/apk/debug/app-debug.apk

6.3 跑在模拟器

  1. 工具栏 AVD Manager → Create Virtual Device → Pixel 7 + API 35
  2. 启动模拟器(等 30s-1min)
  3. Run ▶️

模拟器坑:

  • 模拟器自己的 IP 是 10.0.2.2,不是 127.0.0.1
  • 但我们的目标是 207.57.129.228(真实服务器),模拟器直连外网就行,无需特殊配置
  • 如果你只是想测本地后端,临时把 network_security_config.xml 改成 10.0.2.2 白名单

6.4 跑在真机

  1. 设置 → 关于手机 → 连续点 7 次"版本号" → 开启开发者模式
  2. 设置 → 系统 → 开发者选项 → 打开 USB 调试
  3. USB 连电脑 → 手机弹"允许 USB 调试" → 确定
  4. Studio 右上角设备列表选你的真机 → Run ▶️

7. 配置服务器地址(可切换 debug / release)

MVP 写死在 BuildConfig

app/build.gradle.ktsdefaultConfig 加:

defaultConfig {
 // ...
 buildConfigField("String", "API_BASE_URL", "\"http://207.57.129.228:3000/api/v1/\"")
}

buildTypes {
 debug {
 buildConfigField("String", "API_BASE_URL", "\"http://207.57.129.228:3000/api/v1/\"")
 }
 release {
 buildConfigField("String", "API_BASE_URL", "\"http://207.57.129.228:3000/api/v1/\"")
 // 后续要换 https,改这里 + 加证书
 }
}

代码里用:

@Provides @Singleton
fun provideRetrofit(okHttp: OkHttpClient): Retrofit =
 Retrofit.Builder()
 .baseUrl(BuildConfig.API_BASE_URL)
 .client(okHttp)
 .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
 .build()

8. 调试技巧

8.1 OkHttp 日志

@Provides @Singleton
fun provideOkHttp(
 tokenStore: TokenStore,
): OkHttpClient =
 OkHttpClient.Builder()
 .addInterceptor(AuthInterceptor(tokenStore))
 .addInterceptor(HttpLoggingInterceptor().apply {
 level = if (BuildConfig.DEBUG) Level.BODY else Level.NONE
 })
 .authenticator(TokenAuthenticator(tokenStore, ...))
 .connectTimeout(15, TimeUnit.SECONDS)
 .readTimeout(30, TimeUnit.SECONDS)
 .build()

Level.BODY 会在 Logcat 打印所有请求和响应体(包含 token!小心)。

生产包务必改回 Level.NONE

8.2 Compose Preview

@Preview(showBackground = true)
@Composable
fun ArticleCardPreview() {
 DiaryNewsTheme {
 ArticleCard(
 a = Article(
 id = 1,
 sourceName = "DW",
 sourceSlug = "dw",
 title = "Sample title",
 titleZh = "示例标题",
 bodyZhText = "示例正文...",
 ...
 ),
 onClick = {},
 )
 }
}

8.3 Layout Inspector

Studio → Tools → Layout Inspector → 选进程 → 实时看 Compose 树。

8.4 抓包

推荐 Charlesmitmproxy:

  • 模拟器:WiFi → 长按 → 修改网络 → 手动代理 → 127.0.0.1:8888
  • 真机:Charles Proxy + 安装证书
  • 注意:mitmproxy 会让 HTTPS 失效(因为加了第三方 CA),我们的 HTTP 不受影响

9. CI(可选,MVP 不强求)

如果你想每次 push 自动出 debug APK:

.github/workflows/build.yml:

name: Build APK
on:
 push:
 branches: [main]
jobs:
 build:
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v4
 - uses: actions/setup-java@v4
 with:
 distribution: 'temurin'
 java-version: '17'
 - name: Grant execute permission for gradlew
 run: chmod +x gradlew
 - name: Build
 run: ./gradlew assembleDebug
 - name: Upload APK
 uses: actions/upload-artifact@v4
 with:
 name: app-debug
 path: app/build/outputs/apk/debug/app-debug.apk

10. 常见报错速查

报错 原因 解决
Cleartext HTTP traffic to 207.57.129.228 not permitted 没配 network_security_config 装 §4
Cannot resolve symbol 'hiltViewModel' 没加 hilt-navigation-compose 装 §2 依赖
Hilt: @AndroidEntryPoint ... missing binding Application 没 @HiltAndroidApp 检查 DiaryNewsApp.kt
KSP not found KSP 版本与 Kotlin 不匹配 检查 ksp 版本号
Plugin [id: 'com.google.devtools.ksp'] was not found settings.gradle.kts 没声明 pluginManagement 块加
Composable invocations can only happen from the context of a @Composable function LaunchedEffect 里调非 suspend 阻塞 withContext(Dispatchers.IO)
JSON decode error: Polymorphic serializer was not found 后端返回了 polymorphic 类型,DTO 没声明 DTO 加 @JsonClassDiscriminator 或 sealed class
401 Unauthorized 一片 Token 过期 / 错 启动 app 看 logcat 的 OkHttp 日志,确认 refresh 接口通不通

11. 发布签名(真要上架时再弄)

# 生成 keystore
keytool -genkey -v -keystore diary-news.jks -keyalg RSA -keysize 2048 \
 -validity 10000 -alias diary-news

# 在 ~/.gradle/gradle.properties 加
DIARY_NEWS_STORE_FILE=diary-news.jks
DIARY_NEWS_STORE_PASSWORD=...
DIARY_NEWS_KEY_ALIAS=diary-news
DIARY_NEWS_KEY_PASSWORD=...

# app/build.gradle.kts
signingConfigs {
 create("release") {
 storeFile = file(providers.gradleProperty("DIARY_NEWS_STORE_FILE").get())
 storePassword = providers.gradleProperty("DIARY_NEWS_STORE_PASSWORD").get()
 keyAlias = providers.gradleProperty("DIARY_NEWS_KEY_ALIAS").get()
 keyPassword = providers.gradleProperty("DIARY_NEWS_KEY_PASSWORD").get()
 }
}
buildTypes {
 release {
 signingConfig = signingConfigs.getByName("release")
 }
}

MVP 阶段不弄,直接 debug APK 装手机上跑就够了。