# 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`)| 4. **Finish** --- ## 2. 改版本目录 打开 `gradle/libs.versions.toml`,**整体替换**为: ```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` 整体替换为: ```kotlin 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 ``` ### 4.2 `app/src/main/res/xml/network_security_config.xml` 新建文件: ```xml 207.57.129.228 ``` ### 4.3 `app/src/main/res/xml/backup_rules.xml`(禁止备份 token) ```xml ``` ### 4.4 `app/src/main/res/xml/data_extraction_rules.xml`(Android 12+) ```xml ``` --- ## 5. ProGuard / R8 规则(发布包必加) `app/proguard-rules.pro`: ```proguard # 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 ```bash # 命令行 ./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.kts` 的 `defaultConfig` 加: ```kotlin 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,改这里 + 加证书 } } ``` 代码里用: ```kotlin @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 日志 ```kotlin @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 ```kotlin @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 抓包 推荐 **Charles** 或 **mitmproxy**: - 模拟器:WiFi → 长按 → 修改网络 → 手动代理 → 127.0.0.1:8888 - 真机:Charles Proxy + 安装证书 - 注意:**mitmproxy 会让 HTTPS 失效**(因为加了第三方 CA),我们的 HTTP 不受影响 --- ## 9. CI(可选,MVP 不强求) 如果你想每次 push 自动出 debug APK: `.github/workflows/build.yml`: ```yaml 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. 发布签名(真要上架时再弄) ```bash # 生成 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 装手机上跑就够了。