一个有 App 的团队,"发版本"和"让用户升上来"是两件事。前者你熟:改代码、打生产包、传到某个地方、发个链接让人重装。后者才是麻烦的:手机怎么知道有新版本?谁告诉它新版本叫什么、多大、更新了什么、去哪儿下?如果这套东西还要同时服务 13 套后端框架,那它就必须是一次设计、处处一致的,而不是每套框架各自发明一遍。
我们这套工作流的手机审批端,刚跑完一轮真实的自我升级;借这个由头把这块业务从头到尾讲清楚:一共九道,每道谁做、落在哪个技术栈上,以及为什么后端一格数据库都没建。
一、九道:四道在研发侧,五道在用户侧
研发侧(每次发版走一遍)
- 版本号双轨 bump。
versionName是给人看的(0.2.3),versionCode是拿来比的(19,只增不减)。两个都写在manifest.json里,一次改齐。 - 云证书云打包。HBuilderX 云打包出生产签名的 apk,24.6MB 一个文件。
- 传分发平台。上传时把
versionCode作为平台的buildVersionNo,更新说明也写在这一层——说明文字属于这一次发布,不属于代码仓库。 - 发布态核验。回查平台:
buildVersionNo=19、isLastest=1。这一步做完,"世界上存在 19 这个版本"才成立。
用户侧(每台手机各走一遍)
- 两个入口。冷启动后延后一拍静默检查;「我的」页一个「检查更新」菜单项,「关于」弹窗里还有一次。静默的含义是:无更新和检查失败都不出声,只有真有新版本才弹窗。
- 后端问平台比大小。App 带着
platform和本机versionCode来,后端去平台要"当前最新版",回来比个大小。免登录。 - 弹窗。标题里的版本号、正文的大小和更新说明,App 一个字都不写,全部来自后端字段。强制更新时不给"稍后再说"。
- 不遮手下载。底部一条进度浮层,用户可以继续用 App;下完先问一句"是否立即安装",不抢操作。
- 系统安装器。App 把包准备好、把安装器拉起来,剩下交给安卓。装完
versionCode变 19,用户数据不丢。
九道里,1–4 是发版业务,5–9 是升级业务,中间靠"平台上有 19 这个版本"这一个事实对接。这就是不建表的前提:两边都指向同一个真相,就不需要有人手工抄一遍。
二、版本号为什么要双轨
versionName 和 versionCode 不是一个东西,混了会出事:
versionName是展示用的字符串,0.2.10和0.2.9谁新?字符串比不出来,得拆段。versionCode是整数,210 > 209,一次比较就完事。安卓的安装器也认它——只有versionCode更高,系统才认为这是"升级"而不是"新装",才会给出"现有的数据不会丢失"那句话。
对上分发平台时有个字段坑值得单独说:平台返回的 buildVersionNo 才是版本号口径(对应我们的 versionCode),而 buildBuildVersion 是上传计数——它也在涨,长得也像,但它不是版本号。我们的契约里 versionCode 全程走 buildVersionNo 这一路。
还有一条:平台还有个 buildHaveNewVersion 字段,看着正好是"有没有新版本"的意思,但不传比较参数时它恒为 false。所以我们只把它当元数据,比较自己做:latest <= 本机 就是没更新。
三、检查这件事,谁问谁、什么时候问
契约本身定得很少,越少越好传播:
POST /app/appVersion/check 免登录
请求 { "platform": 1, "versionCode": 18 }
platform: 1=Android 2=HarmonyOS 3=iOS
响应 无更新 → { "code": 0, "data": null, "msg": "成功" }
有更新 → { "code": 0, "data": { "hasUpdate": true,
"versionCode": 19, "versionName": "0.2.3",
"downloadUrl": "...", "fileSize": 24609293,
"fileSizeInfo": "23.5MB", "releaseNotes": "...",
"forceUpdate": false }, "msg": "成功" }
参数不合法 → HTTP 200 + code=99999999 + 具体 msg
三个业务决定值得说明白:
为什么免登录。 更新检查发生在启动路径上,用户可能还没登录(甚至不打算登录)。这个端点不能要求任何身份,也不能因为拿不到身份就失败。
为什么"无更新"是 data: null 而不是 hasUpdate: false。 因为对 App 来说,"没有可用更新"和"这次问不出结果"应该走同一条代码分支——什么都不做。用一个空值表达"没有东西要给你",比造一个布尔字段再让 13 套实现各自决定怎么填它更省事。契约里额外钉了一条:这个键必须存在(字面 null),否则跨栈对拍时"字段缺失"和"值为 null"会被当成两件事。
为什么参数错误也是 HTTP 200。 整套框架的错误信封就是 code + msg,业务错误不占用 HTTP 状态码。检查更新的调用方只看 code,这样 13 套后端不需要各自维护一套 HTTP 语义。
至于失败:基准实现里"未配环境变量 / 平台不支持 / 请求失败 / 解析失败 / 平台返回非零 code / 版本号取不出整数"这六种情况,对 App 只有一种说法——data: null,日志里各留一条。理由很实在:用户打开手机是为了审批单子,不是为了看你的更新服务挂了。
四、13 套后端怎么说法一致
这是这块业务在我们工程里最值钱的部分。13 套框架(Java boot2/boot3/boot4、Go goframe/gin/hertz、Python fastapi/flask/django、Node nestjs、PHP laravel、Rust salvo、C# csharp)都要有同一个端点,做法是契约先行:
- 先在契约文档里把上面那份请求/响应定死,含"任何失败降级为
data:null"这条口径; - 基准实现(GoFrame)先落地,作为其余 12 套的对照;
- 契约测试跑手(
contract_runner.py)新增第 11 组用例,默认批次从 10 组加到 11 组,13 套各跑一遍。
第 11 组只有三条断言,但设计上有意思:13 套要各跑一遍,可 API Key 按红线不进仓库、测试机上也没有真值,正向分支怎么断言?
"""免登录端点,三条断言全部环境无关:参数错按全局约定精确断言 99999999;
versionCode 传 2^31-1 必高于任何已发布 buildVersionNo,故"未配 PGYER_* / 已配 /
蒲公英不可达"三态同判 data=null,无需真值 env 即可跑。hasUpdate=true 正向分支依赖
真实蒲公英 key 与线上版本,不进 runner(由各栈收口报告的手工 curl 矩阵覆盖)。"""
versionCode = 2147483647 必然高于任何真实发出去的版本号,于是"没配 key / 配了 key / 平台挂了"三种环境收敛成同一个期望值,一条断言在零配置机器上也成立。反过来,hasUpdate=true 故意不写进自动化——它依赖线上真值,写进 runner 只会每天随机红,最后所有人学会忽略它。
另外两条支撑一致性的设计:
- 扩平台 = 配一个环境变量。
platform → PGYER_APP_KEY_ANDROID / _HARMONYOS / _IOS是一张字典,将来要加第四个平台,改配置不改代码;没配的那个平台一律视为无更新。 - 真值由部署环境注入。 没配 key 的部署,这个端点恒返回"没有更新"。功能在,但不误报——这比"没配就抛错"友好得多,也比"没配就返回一个假地址"安全得多。
13 遍写下来,也确实攒了一本跨栈差异的账,挑三条:
fileSize的 JSON 类型。 Java 和 C# 有全局的 Long→String 序列化器(防前端 JS 精度丢位),同一个字段在别的栈是数字、在这两栈是字符串。最后把它声明成Integer:24.6MB 装得下;真超过 int32 就省略这个字段,只给fileSizeInfo("23.5MB" 这种给人看的串)。契约宁可少给一个字段,也不要 13 套给两种类型。- Django 的响应冒号后带空格。 DRF 原样输出
{"code": 0, ...},其余 12 套是紧凑串。跨栈探针若按"code":0紧串匹配,django 会假报失败。最后探针统一"code":\s*0——契约只锁字段和取值,不锁空白。 - Hutool 6 的超时。 Java 侧按常规写法设了超时,实测要 21 秒才失败:
ClientConfig设的超时被 SPI 选中的HttpClient4Engine覆盖了,得显式指定 engine。一个挂在启动路径上的接口挂 21 秒,比返回错误码恶劣,所以这类"设了但没生效"的配置必须真机计时,不能看代码。
五、下载和安装:交互是被业务事实决定的
先说那个业务事实:包 23.5MB。二十多兆的东西,从点下"立即更新"到能装,中间总有一段用户明显感觉得到的等待——够他切去回条消息、翻两条审批。
这段等待直接决定了三件事:
必须不遮手。 弹一个"正在下载,请勿退出"的遮罩,等于在那段等待里把整个 App 锁住。而用户点开"检查更新"的时候,往往正是他想继续用这个 App 的时候。所以下载态挂在模块级 reactive 单例上,浮层只是订阅它、渲染在页面根部:不随页面滚动、不拦任何点击、切页之后照样在、百分比照样涨。
下完要问一句。 等完这一趟,用户可能早就切去干别的了,直接跳系统安装器会很突兀。所以是"新版本已下载完成,是否立即安装?"+ 稍后安装 / 立即安装。
重复检查要有闸门。 下载在飞的时候再点"检查更新",不该发第二个请求,只提示一句"新版本下载中"。
安装这一段,业务的边界要说清楚:App 只负责把包准备好,装不装得成不由它决定。 uni.installApk 把系统安装器拉到前台之后,剩下的界面、授权、"是否覆盖安装"全是安卓的。所以更新链的最后一道,其实是用户手机上的一个系统开关——这也是为什么安卓要在 AndroidManifest.xml 里显式声明 REQUEST_INSTALL_PACKAGES。
强制更新走的是同一道链,差别只在弹窗少一个按钮:后端拿平台配置的 forceUpdateVersionNo 和本机 versionCode 比一下,得出 forceUpdate,App 侧据此决定给不给"稍后再说"。判定在数据源那一层,不在客户端——否则用户只要不点那个按钮,或者装一个旧版本,判定就被绕过了。
上面这五道,09-22 在模拟器上真跑了一遍,从冷启动弹窗到"应用安装完成"每一帧:
六、蒲公英只是内置的一种实现,改造点只有一个
先把"为什么不建表"说完整:不是表不该有,是不想为它养一套管理面。 要做"自己管版本",代价不是一个接口,是一整套——版本记录的增删改查、后台管理页、安装包上传与存放、下载地址的鉴权和有效期、谁有权发版、发错了怎么撤。为这点事维护一套后台,不如把包交给一个专门干这行的平台,让它当版本真相的源头。
但这个选择没有被写进契约。契约锁住的只有三样:请求 {platform, versionCode}、data:null 表示没有更新、有更新时给 versionCode / versionName / downloadUrl / fileSize / releaseNotes / forceUpdate。这几个字段从哪来,契约不管。
所以想自己管版本的人,改造面就一处——把"最新版从哪来"那一步换掉:
现在:读 PGYER_API_KEY + 平台 appKey → 问平台要最新版 → 取 buildVersionNo → 比大小
改成:读自己的 sys_app_version 表 → 取该平台 version_code 最大的一行 → 比大小
比大小、组装响应、失败降级为 data:null、免登录、错误信封,全部原样留着。App 侧一行都不用改;13 套后端的契约测试也照跑——那三条断言只吃 platform 和 versionCode,不关心数据源长什么样。
要换的这三样东西,顺便说清楚会多出什么活儿,免得改造时踩空:
- 下载地址要自己生成。 平台给的是带签名的时效直链;自己存就得自己回答"包放哪儿、这个地址能不能长期公开、要不要鉴权"。
- 更新说明要自己录。 现在它跟着那次发布写在平台上;自建之后它变成表里的一个字段,也就变成了一个会忘记填的字段。
- 发版动作要有人管。 往表里插一行就等于发版,所以"谁能插、插错了怎么撤"得有个说法——这正是当初不想要的那套后台。
一句话:内置的是"问一个外部数据源要答案",不是"蒲公英"。 答案的格式由契约锁定,答案的来源留给你换。
七、发版侧接回工程
研发侧那四道,在我们这儿是一条命令链,走的是 jeeflow-app-release 那条发版流程:
manifest.json 双轨 bump(0.2.2/18 → 0.2.3/19)
→ HBuilderX 云打包(生产证书,签名与历史版本一致才能覆盖安装)
→ 上传分发平台(buildVersionNo=19 + 更新说明)
→ 回查发布态(isLastest=1)
→ 用户侧下一次冷启动就能收到
签名这件事容易被忽略:覆盖安装要求新旧包签名一致。所以生产签名必须固定在同一套证书上,换证书等于让所有老用户先卸载再装——更新链直接断掉,而且不会有任何报错,只会表现为"检查更新说没更新"。
后端侧同理:某套栈升级了框架、重出了镜像,这个端点不需要跟着改——它的行为只由契约和部署时的两个环境变量决定。这也是"不建表"换来的另一样东西:发版链和解耦的后端链互不阻塞。
八、边界
写清楚哪些还没做,免得被当成已验证:
- 强制更新(
forceUpdate=true)分支只到代码级,线上没配过强制更新版本号,那条"不给稍后再说"的路径没真值跑过。 - 官网一键脚本部署出来的 13 张镜像,都还没配
PGYER_*,那条路上的这个端点目前恒返回"没有更新",要用的时候部署方自己配。 - App 侧只发安卓包。iOS 走的是另一套分发规则(平台不允许你自己拉包装),契约里的
platform=3是为将来留的口子。 - 上一节那条"换成读自己的表"是留好的改造点,不是已经做完的第二套实现——我们没有自建版本表和后台,那条路上线前需要自己补齐上传、存放和鉴权。
回过头看,这块业务的方法论就三条:
- 两边指向同一个真相,中间就不用抄。 版本号的真相在分发平台上,抄一份进表,就多一个会忘记同步、且出错不报错的地方;真要自己管,换掉取数那一步就行,契约和 App 都不动。
- 契约要少到能传播,要严到能对齐。 少到只剩一个请求体和"没东西就返回 null",才谈得上 13 套各自实现;严到把"失败也必须是这个形状"写死,跨栈对拍才有意义。
- 交互设计先量一个物理量。 包多大、这段等待有多长,这两个事实一摆,"要不要遮手"就没有争论空间了——不用吵,也不用回头问产品。
参考资料
- jeeflow 文档站:jeeflow-doc.mldong.com/
- jeeflow 开源演示站:jeeflow-demo.mldong.com
- 移动端审批端(开源):github.com/mldong/uni-…
- 蒲公英开放 API(
apiv2/app/check):需在平台后台申请 API Key,按平台维度配PGYER_API_KEY+PGYER_APP_KEY_ANDROID/_HARMONYOS/_IOS