一个移动应用项目显示为 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.propertiesCI 上没有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: ListOrder) : 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 原生项目通常用 EspressoRunWith(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.ymlname: Android release check on: push: tags: - v* jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkoutv4 - name: Set up JDK uses: actions/setup-javav4 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-artifactv4 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 包在干净环境能稳定构建并通过冒烟测试”这件事解决。这是整个发布流程里最不可妥协的底线。之后再看监控、灰度、回滚这些保障手段。顺序对了上线后的风险会小很多。