一个移动应用项目显示为 90% 时,通常意味着主干功能已经能跑通:界面可以打开、登录可以走完、核心业务操作可以演示。但在真实团队里,从 90% 走到 100% 从来不是简单补一个进度数字,而是要把工作模式从“继续加功能”切换到“为发布收口”。Hermes Studio App 目前就处在这个节点,接下来真正要处理的是:还有哪些异常分支没覆盖、哪些构建配置是临时值、release 包能不能稳定打出来、真机表现是否和模拟器一致、上线后出问题能不能快速定位和回滚。这篇文章以 Hermes Studio App 为例,把移动应用最后 10% 的开发与发布工程化工作拆成可执行的检查项,适合正在从功能开发转向发布准备的 App 项目团队参考。
1. 90% 只代表功能闭环,不代表发布闭环
1.1 功能完成度与发布就绪度不是同一个指标
很多项目会把“开发进度 90%”和“可以上线”混在一起判断。实际上,功能完成度回答的是“功能做没做出来”,发布就绪度回答的是“这个版本能不能安全交给用户”。
一个功能完成度 90% 的项目,可能登录、列表、详情、支付、分享等主干流程都已经有可用版本。可是用户点击“忘记密码”时验证码是否正常、弱网下请求超时后页面会不会一直转圈、服务器返回空列表时界面是否显示空状态、权限被用户拒绝后有没有后续引导,这些都不属于主干功能,却直接影响用户是否愿意继续使用。
从发布角度检查,问题会更明显:版本号没有按规则递增会覆盖不了旧包;签名文件无法复现会导致升级失败;release 包开启混淆后某些序列化类被删除会直接崩溃;CI 环境和本地环境 JDK 不一致会导致团队内部只有一个人能打出可用包。
所以 90% 是一个偏乐观的开发进度描述。若把这个数字换算成发布进度,多数项目其实还处于 40% 到 60%。
1.2 最后 10% 要做哪些工作
最后 10% 不应被理解成“再写几个功能”,而应该拆成八类工作:
- 异常分支和边界场景补全,包括超时、断网、空数据、服务端数据不完整。
- 权限被拒绝、被永久拒绝、用户撤回授权等系统交互路径。
- 构建配置收口,包括版本号、签名、混淆、资源压缩、环境地址。
- 自动化测试补齐,包括单元测试、UI 冒烟测试、真机回归。
- CI/CD 流水线搭建,保证每次提交都能在干净环境完成检查。
- 内部测试包分发和灰度发布路径确认。
- 崩溃监控、用户反馈、数据埋点上线前配置完成。
- 回滚方案和紧急发版流程演练。
下面每一章都对应其中一块工作。建议按顺序阅读,先理解为什么做,再对照自己的项目去执行。
2. 先做项目健康度检查,再补功能
2.1 构建链路的版本一致性
90% 阶段最容易出现的情况是:功能在开发机可以运行,但在同事电脑、CI 机器上无法构建。原因通常不是代码问题,而是 JDK、Gradle、Android Gradle Plugin、Kotlin 插件版本不一致。
先做一次完整的环境盘点。以 Android 原生项目为例,在项目根目录执行:
cd HermesStudioApp java -version ./gradlew --version adb devices./gradlew --version会输出当前使用的 Gradle 版本和 JVM 版本。如果本机装的是 Java 17,项目 Gradle 8.x 可以正常运行;如果 CI 机器还是 Java 8,就会直接报出不兼容的提示。
实际维护时建议把关键版本确认后写入项目文档,避免新人拿到项目后先花半天配环境:
| 环境项 | 建议确认方式 | 常见不一致原因 |
|---|---|---|
| JDK 版本 | java -version | 本机多个 JDK 导致 IDE 和命令行不一致 |
| Gradle Wrapper | 查看gradle/wrapper/gradle-wrapper.properties | 有人手动用全局 Gradle 执行 |
| Android Gradle Plugin | 查看根目录build.gradle.kts | 不同分支升级了 AGP 未同步 |
| Kotlin 版本 | 查看libs.versions.toml或 build 脚本 | IDE 插件版本和项目版本不一致 |
| Android SDK 路径 | 查看local.properties | CI 上没有local.properties |
| 连接设备 | adb devices | 多台设备或模拟器未释放 adb 端口 |
注意:不要用“我本地能编”作为发布依据。CI 上跑过一次干净构建,比任何口头确认都可靠。
2.2 代码里的临时状态要清除
90% 进度的代码库里通常残留着调试入口、测试按钮、写死的 token、本地代理地址、todo 标记。发布前需要系统性搜索一遍。
在代码目录执行:
grep -rn "TODO\|FIXME\|HACK" app/src || true grep -rn "http://" app/src/main || true grep -rn "password=\|secret=\|token=" app/src/main || true执行结果需要人工确认。http://不一定是问题,但如果是明文接口地址,说明环境配置还没有外置;如果是第三方 SDK 的授权本地回调,可能有正常用途,要在注释里写清楚用途。
BuildConfig.DEBUG也要检查。常见错误是临时功能只写成这样:
if (BuildConfig.DEBUG) { // 测试环境专用入口 startActivity(Intent(this, DebugActivity::class.java)) }调试入口本身不是问题,问题在于 release 构建时,混淆和裁剪是否把这些代码正确移除。更稳妥的做法是用单独的 sourceSet 或 productFlavor 管理 debug UI,而不是在主代码里散落DEBUG分支。
2.3 依赖状态在发布前要锁住
最后 10% 阶段不适合频繁升级第三方库。依赖升级表面上只改一个版本号,实际可能改变混淆规则、网络序列化字段、生命周期回调行为。
先导出一份当前依赖树,用作基线:
./gradlew :app:dependencies --configuration debugRuntimeClasspath > dependencies-debug.txt这份文件可以进 Git 仓库,也可以放在发布记录里。后续如果发现依赖冲突,先对比这份文件,能快速判断是哪次升级引入的。
如果项目使用 Gradle Version Catalog,典型配置在gradle/libs.versions.toml:
[versions] agp = "8.5.2" kotlin = "2.0.0" [libraries] androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.13.1" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }这里给出的版本号只是结构示例,不要照抄到生产项目。版本目录的价值是把依赖版本集中管理,避免多个 module 里同一个库出现不同版本。
3. 最后的功能补全要围绕异常分支展开
3.1 网络、空数据和登录态过期
到了 90%,网络请求的成功分支基本已经写完,但失败分支未必完整。比如接口返回 200 但 body 是 null,响应码是 401、403、500,网络超时,JSON 解析失败,这四种情况都应该有独立处理。
以登录接口为例,可以把结果建模成封闭类型,强制调用方覆盖每个分支:
sealed class LoginResult { data class Success(val token: String, val userId: String) : LoginResult() data class Error(val code: Int, val message: String) : LoginResult() object NetworkError : LoginResult() }请求逻辑里不要只写try-catch,还要区分业务失败和系统失败:
suspend fun login(account: String, password: String): LoginResult { return try { val response = api.login(LoginRequest(account, password)) if (response.isSuccessful) { val body = response.body() if (body != null && body.token.isNotBlank()) { LoginResult.Success(body.token, body.userId) } else { LoginResult.Error(-1, "服务端返回数据不完整") } } else { LoginResult.Error(response.code(), response.errorBody()?.string() ?: "请求失败") } } catch (e: IOException) { LoginResult.NetworkError } catch (e: Exception) { LoginResult.Error(-2, e.message ?: "未知异常") } }这段代码里,IOException代表网络抖动、超时、DNS 解析失败,用户看到“网络异常”文案后可以选择重试;其他异常被转成统一Error,避免异常文本直接暴露给用户。
登录态过期也需要全局处理。不能只在某个页面判断 401,而应该由统一的响应拦截器识别,再触发重新登录流程。否则用户在一个页面退出登录后,其他页面仍然带着旧 token 请求。
3.2 权限被拒绝后的二次引导
权限处理是 90% 进度时容易被低估的部分。很多团队只测试了“用户同意授权”这一条路径,没有测试“用户拒绝一次”“用户勾选不再询问”“用户在系统设置里关闭权限”三条路径。
推荐做法是把权限请求封装成可复用的流程,而不是在 Activity 里散落:
class MainActivity : AppCompatActivity() { private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (granted) { startCamera() } else { showPermissionRationale() } } private fun ensureCameraPermission() { when { ContextCompat.checkSelfPermission( this, Manifest.permission.CAMERA ) == PackageManager.PERMISSION_GRANTED -> { startCamera() } shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> { showRationaleDialog() } else -> { requestPermissionLauncher.launch(Manifest.permission.CAMERA) } } } }其中shouldShowRequestPermissionRationale只有在用户拒绝过权限且没有选择“不再询问”时才返回 true。如果用户已经彻底关闭权限,应用内再次申请也不会弹系统框,这时应该给出“去设置页打开权限”的引导按钮,并跳转Settings.ACTION_APPLICATION_DETAILS_SETTINGS。
3.3 列表页的加载、空态、错误态要分开渲染
很多 App 到了 90% 时,列表页只有“加载成功有数据”的状态。用户看到的场景其实是四选一:
- 正在加载。
- 加载成功,有数据。
- 加载成功,但没有数据。
- 加载失败,需要重试。
后两种如果开发者没有处理,用户会看到白屏或一直转圈。可以把列表页状态建模成一个可观察对象:
sealed class OrderListState { object Loading : OrderListState() data class Content(val orders: List<Order>) : OrderListState() data class Empty(val message: String) : OrderListState() data class Error(val message: String, val canRetry: Boolean = true) : OrderListState() }UI 层根据状态分别绑定加载中视图、列表内容、空状态视图、错误重试视图。越是接近发版,越应该把这种状态机在页面上统一,而不是每个页面自己写一套判断。
4. 版本号、签名、混淆和构建环境要提前收口
4.1 版本号必须能反映升级关系
Android 使用versionCode作为内部版本号,它必须是单调递增的整数;versionName是用户可见版本号。发布前最容易出错的是:versionCode没有递增,导致用户安装了新包却无法覆盖旧包。
以 Kotlin DSL 项目为例,app/build.gradle.kts中的默认配置:
android { namespace = "com.hermesstudio.app" compileSdk = 34 defaultConfig { applicationId = "com.hermesstudio.app" minSdk = 23 targetSdk = 34 versionCode = 21 versionName = "1.2.0" } }这里的compileSdk = 34、minSdk = 23、targetSdk = 34只是示例,实际取值要以项目需求为准。发布前要确认minSdk是否覆盖目标用户设备、targetSdk是否满足应用市场要求。
建议在版本目录或构建脚本里形成约定:
versionCode = 21 // 每次发布递增 versionName = "1.2.0" // 语义化版本如果公司有自动生成版本号的规则,比如按日期生成2025090101,也要提前定好,不要等到打包时临时改。
4.2 release 签名和密钥管理
release 包必须用稳定签名。90% 阶段如果一直用 debug 签名发测试包,到了上市场时会遇到两个问题:签名密钥信息没人知道、新签名的包无法覆盖旧签名包。
本地生成一次性密钥可以这样执行,但实际企业项目应该由专人管理:
keytool -genkey -v -keystore hermes-studio-release.keystore \ -alias hermes-studio \ -keyalg RSA \ -keysize 2048 \ -validity 3650构建脚本不要硬编码密码。推荐从环境变量读取:
android { signingConfigs { create("release") { val keystoreFile = System.getenv("KEYSTORE_FILE") if (!keystoreFile.isNullOrBlank()) { storeFile = file(keystoreFile) storePassword = System.getenv("KEYSTORE_PASSWORD") keyAlias = System.getenv("KEY_ALIAS") keyPassword = System.getenv("KEY_PASSWORD") } } } buildTypes { getByName("release") { isMinifyEnabled = true isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) signingConfig = signingConfigs.getByName("release") } } }签名文件和密码绝对不能进 Git 仓库。keystore文件可以放在受保护的目录,密码在本地放在本机环境变量,在 CI 里放到 Secret 存储中,不要在 build 日志里打印。
4.3 R8 压缩和资源裁剪要尽早打开
90% 阶段为了调试方便,很多项目 release 构建没有开启minifyEnabled。直到发版前最后一次打包才发现 R8 报错,或者打开之后某个反射类被裁剪导致运行崩溃。
R8 在 Android 项目中承担代码压缩、资源裁剪、混淆和优化四个任务。开启后常见问题是:
- 使用了反射的类没有被保留。
- Gson、Moshi、kotlinx.serialization 等序列化库的模型类被混淆。
- 通过
getDeclaredField访问的字段被改名。 - 第三方 SDK 没有加入对应 keep 规则。
建议在正式发版前两周就把isMinifyEnabled = true打开,并把 release 构建作为日常验证的一部分。如果项目用 Gson,通常需要在proguard-rules.pro中保留模型:
-keep class com.hermesstudio.app.data.model.** { *; }这段规则只解决模型类保留问题。第三方 SDK 的混淆规则要参考各自文档,遇到崩溃时先看堆栈是否指向被混淆的类。
注意:混淆规则不是“越少越好”,也不是“全部 keep”。目标是让稳定发布的 release 包和本地 debug 行为一致,同时保留必要的信息用于崩溃堆栈还原。
5. 测试的覆盖重点从主流程转移到回归和兼容
5.1 单元测试:先验证规则和状态机
到了 90%,UI 手点测试已经不能保证质量。重量级的功能模块应该有单元测试,特别是登录校验、金额计算、状态流转、权限判断这类规则集中的代码。
例如账号密码校验:
class LoginRequestValidatorTest { @Test fun `account or password empty should fail`() { val result = LoginRequestValidator.validate("", "") assertFalse(result.isValid) } @Test fun `password length less than 6 should fail`() { val result = LoginRequestValidator.validate("hermes", "123") assertFalse(result.isValid) } }执行命令:
./gradlew testDebugUnitTest这些测试不依赖真机,执行速度快,适合在每次提交时运行。
5.2 UI 测试:把关键链路写成自动化冒烟
单元测试覆盖不到页面跳转、权限弹窗、列表刷新、状态切换这些交互问题。建议至少为登录、首页加载、核心业务创建三个 UI 冒烟用例。
Android 原生项目通常用 Espresso:
@RunWith(AndroidJUnit4::class) class LoginScreenTest { @Test fun clickLogin_withEmptyAccount_showError() { ActivityScenario.launch(MainActivity::class.java).use { onView(withId(R.id.btn_login)).perform(click()) onView(withText(R.string.error_account_empty)).check(matches(isDisplayed())) } } }执行前需要连接模拟器或真机:
./gradlew connectedDebugAndroidTestUI 测试写多了会慢,发版前可以只跑冒烟集,但日常跑全量更安全。
5.3 用真机矩阵验证兼容性与性能
模拟器能验证大部分逻辑,但无法替代真机。至少准备一台低端 Android 手机、一台最新系统手机、一台旧系统手机做兼容性回归。
测试矩阵可以这样列:
| 测试项 | 低端机 | 主流机 | 最新系统 |
|---|---|---|---|
| 冷启动时间 | 记录白屏时长 | 记录启动耗时 | 对比启动耗时 |
| 列表滑动 | 检查卡顿和掉帧 | 检查内存抖动 | 检查新系统适配 |
| 权限弹窗 | 拒绝后重进 | 设置页关闭权限 | 系统级权限变化 |
| 网络切换 | 飞行模式恢复 | 弱网请求超时 | 不同 WiFi 下重试 |
| 后台恢复 | 进程被杀后恢复 | 长时间切后台 | 低内存回收 |
如果团队没有多台真机,可以考虑用云测平台或内部设备共享方案。使用云测平台时,要注意测试账号和测试数据不能包含真实用户隐私。
6. 用 CI 流水线把 90% 到发布这段自动化
6.1 最小可用流水线长什么样
连续集成不是只做构建,而是让每次代码提交都能自动执行:单元测试、Lint 检查、构建 debug 包;打 tag 时再执行发布构建。
以 GitHub Actions 为例,最小流程可以写在.github/workflows/release.yml:
name: Android release check on: push: tags: - "v*" jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up JDK uses: actions/setup-java@v4 with: distribution: temurin java-version: "17" - name: Run unit tests run: ./gradlew testDebugUnitTest - name: Build release apk env: KEYSTORE_FILE: ${{ secrets.KEYSTORE_FILE }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} run: ./gradlew assembleRelease - name: Upload release apk uses: actions/upload-artifact@v4 with: name: app-release path: app/build/outputs/apk/release/app-release.apk在这个文件里,secrets.KEYSTORE_FILE是 Base64 编码后的 keystore 内容,KEYSTORE_PASSWORD、KEY_ALIAS、KEY_PASSWORD是密钥信息。所有机密都不能明文写在仓库中。
这套流水线能保证:只有打v1.2.0这类 tag 时才会走发布构建,普通分支提交只做基础检查。
6.2 CI 环境与本地环境的差异要提前处理
CI 常见报错是本地构建成功、CI 构建失败。大多数原因是环境差异:
- CI 没有 Android SDK,或者缺少某个 build-tools。
- CI 的 JDK 版本和本地不同。
- CI 没有
local.properties,导致找不到 SDK。 - CI 的网络无法访问部分第三方仓库。
- Windows 本地的文件路径大小写问题在 Linux CI 上暴露。
排查时先在 CI 日志里看第一个失败步骤,逐步确认 JDK 版本、Gradle Wrapper、SDK 目录、依赖下载结果。不要把本地能构建当作充分条件。
6.3 发布门槛检查清单
CI 通过后还要人工确认一份清单:
| 检查项 | 通过标准 |
|---|---|
| 单元测试 | testDebugUnitTest全部通过 |
| UI 冒烟 | 核心链路无失败 |
| 版本号 | 比线上版本更高且规则一致 |
| 签名 | 使用正式签名而不是 debug 签名 |
| 混淆 | release 包启动无崩溃 |
| 环境地址 | 包内指向生产环境或正式测试环境 |
| 日志开关 | 没有泄露请求参数或 token |
| 安装升级 | 能覆盖上一个正式版本 |
这张表可以贴在发布记录里,每次发版前逐项打勾。它比单个人记在脑子里可靠得多。
7. 上线不是终点:监控、开关和回滚
7.1 崩溃监控要先于用户反馈
如果崩溃监控是上线后才接入,那么用户遇到问题只会先在应用市场打低分,开发团队还不知道发生了什么。建议在发布前一天就确认崩溃监控已经接入,并在测试包中用一次主动触发来验证上报链路。
在 Application 初始化时区分环境:
class HermesApplication : Application() { override fun onCreate() { super.onCreate() if (BuildConfig.DEBUG) { crashMonitor.setEnabled(false) } else { crashMonitor.install(this) } } }这里的crashMonitor是抽象写法,实际项目需要替换为团队选定的监控 SDK。重点不是代码本身,而是这个开关必须在发版前验证一次。
7.2 发布策略:小范围、灰度、快速回滚
不要第一天把 100% 用户都切到新版本。成熟做法是先让内部人员试用,再放给少量外部用户,最后逐步扩大比例。
如果应用市场支持分阶段发布,节奏通常是这样:
- 内部测试:构建包发给 QA 和核心体验群。
- 灰度发布:新版本只对 5% 到 10% 用户可见。
- 观察监控:检查崩溃率、关键接口错误率、用户反馈。
- 逐步放量:数据稳定后提升到 50%、100%。
- 紧急回滚预案:如果异常,立刻停止放量并准备上一个版本。
对于 Android 应用,还需要考虑安装包下载成功后可能因为本地旧数据、数据库升级失败等原因导致首启崩溃。如果这类崩溃发生在上线早期,需要尽快定位并发布修复包。
7.3 日志和数据的隐私还有一道底线
发版前需要检查日志和上报内容。不要在日志中打印密码、完整手机号、身份证号、支付 Token。使用成熟监控 SDK 时,要确认是否采集了用户输入文本框内容,避免把敏感字段一起上报。
数据埋点也一样。埋点应该在脱敏后上报,例如手机号只保留前三位和后四位,用户 ID 使用混淆后的内部 ID。上线前一天把埋点事件检查一遍,比上线后从大量日志里捞错误要节省时间。
8. 90% 到发布最常见的问题与排查路径
8.1 debug 正常,release 包异常
现象:debug 包运行正常,release 包启动崩溃,或某个页面点击后闪退。
优先检查是否开启了minifyEnabled和shrinkResources。常见根因是反射、序列化、JNI 相关的类被裁剪或混淆,也可能是资源根据配置被删除,导致Resources.NotFoundException。
排查方式:
adb logcat -s AndroidRuntime:E从崩溃堆栈找到被混淆的类名,再用mapping.txt还原。混淆产物通常位于:
app/build/outputs/mapping/release/mapping.txt处理方式是给相关类补充 keep 规则。修复后重新打 release 包,不要只在 debug 下验证。
8.2 本地能构建,CI 构建失败
现象:本地./gradlew assembleRelease成功,CI 上同一份代码失败。
先检查 CI 日志里失败步骤。常见原因包括 CI 没有安装 Android SDK、JDK 版本不一致、Gradle Wrapper 权限缺失、依赖仓库网络不通。执行./gradlew --version对比本地和 CI 的 Gradle 与 JVM 版本。
如果代码中用到了区分环境的资源,比如debugImplementation引入的库,要确认 release 构建条件没有隐式依赖 debug 内容。
8.3 用户安装更新包提示签名冲突
现象:用户从应用市场或安装包升级时提示“应用未安装”或“签名不一致”。
原因几乎都是当前安装包版本与上一个线上包签名不同。可能是签名文件丢失后重新生成,也可能是构建脚本错误地用 debug 签名打 release 包。
检查当前包签名:
apksigner verify --print-certs app-release.apk确认之后,和线上版本的签名做对比。如果签名不一致,用户只能卸载旧包再安装新包,这个过程会造成用户数据丢失。所以签名文件必须长期妥善保存,并记下密钥别名、加密算法、有效期。
8.4 常见发版问题速查表
| 问题现象 | 可能原因 | 排查入手点 | 处理建议 |
|---|---|---|---|
| release 包体积明显增大 | debug 代码和资源未裁剪 | 对比--configuration releaseRuntimeClasspath依赖树 | 开启 R8,检查是否有重复依赖和未使用资源 |
| 安装后提示解析失败 | 签名配置不正确或构建产物损坏 | apksigner verify | 重新检查 signingConfig,用 CI 重新构建 |
| 版本升级后被系统拦截 | versionCode 未递增 | 对比前后包信息 | 提高 versionCode,统一发布规则 |
| 用户反馈启动白屏 | 首启初始化或数据库升级异常 | logcat、崩溃监控 | 优先定位首屏初始化链路,准备兜底恢复逻辑 |
| 页面点击无反应 | release 混淆导致事件或反射失效 | mapping 还原崩溃堆栈 | 检查自定义 View、点击事件绑定、反射类 keep 规则 |
| 接口请求全部 401 | 登录态存储或 token 刷新逻辑存在状态竞争 | 抓取日志确认 token 刷新时机 | 统一请求拦截器处理 401 并串行化刷新流程 |
9. 收尾前的执行顺序建议
9.1 如果只剩三天,按这个顺序推进
第一天的目标是把风险摸清。检查版本号、签名文件、CI 构建、release 包是否能在干净环境产出,确认代码里没有临时地址和调试入口。
第二天的目标是把异常路径走完。重点测登录态过期、弱网请求、权限拒绝、列表空态。每发现一个问题就记录,不要顺手在线上代码里打补丁式修改。
第三天的目标是把发布路径走通。完成签名 release 包,在测试设备上覆盖安装一次,确认崩溃监控能上报,再走内部测试或灰度发布。
9.2 别把最后 10% 当成可压缩项
90% 到 100% 的真正价值不是多写几个页面,而是把一个能演示的 Demo 变成一个能交付的版本。Hermes Studio App 如果能把异常处理、构建配置、自动化测试、CI/CD、监控和回滚都补齐,这个 90% 才有继续往 100% 推进的基础。
如果只做一件事,先把“版本号、签名、R8 开启后的 release 包在干净环境能稳定构建并通过冒烟测试”这件事解决。这是整个发布流程里最不可妥协的底线。之后再看监控、灰度、回滚这些保障手段。顺序对了,上线后的风险会小很多。