1. 推送不是一套统一接口,而是多套厂商通道的“汇聚层”
先说一个我在学习 Uniapp 推送时绕了很久才想明白的认知:uni.push这套 API 表面上是一套统一接口,但它背后其实是多套不同手机厂商推送服务的汇聚层。说得再直白一点,你没有Apple的推送证书,或者没有在小米、华为、OPPO、vivo 等厂商开放平台挨个注册应用,那么你的 App 就算集成了uni.push,也只能在应用前台存活时勉强收到消息;一旦应用被系统杀掉、或者被用户从后台划掉,消息基本就石沉大海了。
为什么会这样?因为手机厂商为了省电和系统资源,对第三方 App 的后台进程做了非常严格的限制。Android 系统原生的 Google 推送在国内没有意义,而应用自己维持一个长连接在后台的做法,又会迅速被厂商的省电策略杀掉。所以国内 Android 手机最终形成了一套“厂商托管”的推送模式:如果你的 App 想要在“应用被杀掉”的情况下还能收到推送,就必须把消息交给系统级的推送服务去分发——这就是小米推送、华为推送、OPPO 推送、vivo 推送存在的价值。Uniapp 的uni.push就是把这些厂商通道全部接进来,再在上层提供一套统一 API。你在集成层看到的是一个接口,底层实际是几套完全不同的 SDK 和不同的推送协议。
这里还要补充一个容易混淆的概念:Uniapp 面向 App 端时的推送通常称为uniPush,而官方还提供了一个名为 UniPush 2.0 的服务。两者最大的区别在于:UniPush 2.0 帮开发者省去了大量对接厂商 SDK 的工序,你只需要在打包时勾选并填写各厂商的密钥参数,就能以一套代码同时覆盖多个厂商通道。如果是自己写原生插件去接推送,那每个厂商都有单独的 SDK、单独的初始化代码、单独的推送消息接收回调,代码量和工作量都是成倍增长的。所以现在新项目做 App 推送,我是强烈建议直接用 UniPush 2.0,而不是自己写原生插件去挨个对接。
在这个部分,我还想记录一个学习中很容易忽略的点:推送标识的获取。uni.push获取到的是一个称为push_clientid的标识,它是 Uniapp 内部定义的一层映射,并不是厂商自己的推送 token。当你把push_clientid传到自己的业务服务器时,服务器后续发消息需要走 Uniapp 的推送服务,再由它去映射到各厂商的 token。这个机制很像一个“总机转分机”的逻辑:你的服务器只需要认识一个总机号码,具体怎么找到对方手机,由总机去负责。理解这一点之后,后面排查“为什么收不到推送”时,就不会一头雾水地以为是自己的push_clientid有问题,而是会知道问题可能出在“总机到分机”这一段,也就是厂商通道配置是否正确。
2. uniPush 从申请到联调的完整链路与配置要点
接 UniPush 2.0 的第一步,并不是写代码,而是去开通服务并完成一系列的身份关联。直接在官方开发者平台登录账号,在“我的应用”里创建或找到你的 App 应用,然后进入“推送服务”模块,选择开通 UniPush 2.0。这一步通常需要你选择 App 的平台类型,比如 Android 和 iOS 都要勾选,因为后面不同平台要配置的证书或密钥完全不同。我在初次操作时就是只勾了 Android,结果 iOS 端死活收不到推送,后来才发现是自己没把 iOS 平台勾进同一个推送应用里面。这个配置逻辑有点像门禁系统:虽然你在 Android 和 iOS 各有独立的一把钥匙,但门禁名单里必须两个平台都登记了,通道才会对你放行。
开通之后,你需要分别获取各厂商推送服务的相关参数。每个开放平台的入口不一样,但操作流程基本是同一个套路:
- 在厂商开放平台创建应用,包名必须和你的 Uniapp 工程里的 Android 包名完全一致;
- 获取该应用的 AppID、AppKey、AppSecret 之类的凭证;
- 回到 Uniapp 的开发者平台,把这些凭证按字段要求填写到 UniPush 2.0 的配置页里;
- 在 Uniapp 云打包界面勾选“使用 UniPush”,并把厂商凭证填入对应的打包配置中。
这个流程看着不复杂,但有一个极其容易踩的坑:包名不一致。很多人在厂商平台创建应用时,填写的是一个临时的或者测试用的包名,等云打包时又换成了正式包名,最后推送在测试机上永远收不到。因为这个包名是整个厂商通道的“身份证”,一旦 App 的包名不能与厂商平台登记的包名完全匹配,厂商的服务端就会直接拒绝这条推送。我在实践中养成了一个习惯:先确定正式包名,再去所有厂商平台逐个创建应用,并且每填一个字段都做一次复制粘贴确认,绝不手动敲,防止大小写或者多一个少一个字符的问题。
iOS 端的配置则相对独立。首先需要在 Apple 开发者后台生成推送证书,导出为.p12文件,同时在 Uniapp 的配置页里上传并将密码填好。iOS 的推送逻辑和 Android 厂商通道有个不太一样的地方:iOS 的推送由苹果统一接管,并不需要你去挨个对接厂商,但它要求你必须有推送证书,并且在打包时把证书打进 App。如果你的项目在 iOS 上测试时,推送发送状态显示成功但设备就是没反应,通常优先怀疑证书环境,也就是证书是开发环境(Development)还是生产环境(Production)用错了。
配置完成后,下一步是联调。Uniapp 提供了自带的推送测试功能,可以直接在后台向某个push_clientid推送一条测试消息。联调的正确顺序是:先用“通知消息”测离线状态,也就是把 App 切到后台或杀掉,看通知栏是否弹出;再用“透传消息”测在线状态,也就是 App 在前台时能否通过uni.onPushMessage收到数据。如果两条链路都通了,说明基础通道没有问题;如果有一个不通,就可以提前判断是厂商通道问题还是应用内事件监听问题,而不是盲目地去改代码。
我还想提一个细节:push_clientid是会发生变化的。在极少数情况下,比如用户清除了 App 的数据,或者系统更新后推送服务重建了,这个 ID 会重新生成。所以业务服务器不能把push_clientid当成永久绑定,必须在每次启动 App 时主动上报一次,让服务端做覆盖更新。以前有人因为把客户端 ID 当成静态数据缓存起来,结果消息全部发到了一个已经失效的 ID 上,用户自然收不到任何推送。
3. 收不到推送、点击不跳转:从“在线”到“离线”的逐层排查
我在学习推送时,最喜欢做的一件事就是把各种“收不到推送”的场景记录下来,然后逐层拆解。因为推送涉及链路太多,客户端、服务端、厂商通道、Uniapp 中转服务,任何一环出了问题都会表现为“用户收不到”,但根因可能完全不同。
第一种典型场景:App 在前台能收到推送,但按 Home 键退到后台后,通知栏里什么都看不见。这个现象基本可以断定在线通道是通的,离线厂商通道没生效。先检查系统设置里是否允许该 App 发送通知,也就是通知权限是否被关闭;再检查厂商通道的凭证是否正确、云打包时是否勾选了对应厂商通道并填入了密钥;最后检查是否在 App 的 manifest.json 里没有配置推送相关的内容。我遇到过一个案例,就是开发者在厂商平台复制密钥时多复制了一个空格,保存时也没人发现,结果整整浪费了一个下午。
第二种典型场景:应用从后台被清理掉,也就是进程真正被杀之后,推送完全收不到。这种情况比上面的要严重一些。正常情况下,只要厂商通道配置正确,应用即使被杀掉,厂商的系统服务也能收到服务器消息并弹出通知。如果连这个场景都收不到,基本就是厂商通道没有真正生效。除了检查凭证和包名之外,还有一个隐性因素:部分厂商开放平台需要你先通过应用审核,或者在“消息分类”和“推送权限”里做额外的申请,比如小米的“推送服务”权限、华为的高优先级消息权限。这些权限不是开通服务就自动有的,很多新注册的厂商应用默认处于低配额或受限状态,需要先提交审核。所以在排查时,不要只盯代码,一定要去厂商平台后台看应用状态是否正常。
第三种典型场景:通知栏能看到推送,但点击推送后没有跳转到指定页面,或者只是直接打开了 App 首页。这里通常涉及到推送消息的数据结构设计。Uniapp 的推送消息支持携带payload数据,也就是由开发者在后台发送时附加的一段自定义 JSON。点击通知时,App 拿到payload,再根据里面的字段做页面路由跳转。很多新手会在onShow生命周期里处理点击事件,但发现偶尔拿不到参数,这是因为 App 被冷启动和热启动时的场景不一样:热启动时,onShow可能先于推送事件回调触发,导致你还没来得及读取payload,页面就已经跳转了。
更稳妥的做法是在 App.vue 的onLaunch和onShow中分别注册uni.onPushMessage监听,在回调里判断type是否为click类型。如果是点击通知触发的回调,再根据payload做跳转。另外,对于 iOS 和 Android,点击通知后的行为也有细微差异:Android 上某些厂商会默认把payload放到启动 Intent 里,而 iOS 则可能通过回调方式返回。所以实际开发时最好封装一个统一的“解析推送跳转”工具函数,无论是冷启动还是热启动,都走同一个入口处理,避免平台差异带来的逻辑混乱。
我还想强调一个操作性很强的排查技巧:不要只依赖 Uniapp 后台测试,要抓真实生产环境的数据。我在联调时会写一个简单的全局日志面板,把推送事件的原始数据实时打印在屏幕上,比如type、cid、payload,然后用“杀掉进程→发送→冷启动”这个流程去复现。这样能非常直观地看到推送到底有没有被 App 收到、数据是否完整。对于payload解析失败的情况,通常是因为 JSON 格式里带了转义字符或者多包了一层,先打印原始字符串,再尝试解析,问题就能快速定位出来。
4. 热更新的边界:哪些能“热”,哪些只能整包
热更新是 Uniapp 开发里另一个经典话题。所谓热更新,通俗地说,就是用户手机上已经安装了 App,我们不发新包到应用商店,也能把线上版本悄悄升级成新版本。它的原理是:Uniapp 的 App 端应用,主要逻辑代码和页面资源都是以 JS 和静态资源形式存在的,这些内容被打包成一个资源包,在运行时由原生壳去加载。如果我们只更新这里的 JS 和资源,不涉及原生层变动,就可以做到不重新安装 App 就完成版本升级。
这个机制很像在一个房子里换家具:房子的梁和柱是原生代码,家具的位置和款式是资源文件;换家具只需要把旧家具搬走、新家具搬进来,而不需要把整个房子推倒重建。但需要注意,凡是涉及原生能力的变化,比如新增了一个原生插件、修改了打包时的权限配置、改变了 Android 的主包名,这些都必须走整包更新,热更新是覆盖不了的。
在 Uniapp 里,热更新是通过wgt包来实现的。wgt 是资源包的简称,它是一个压缩文件,里面包含 App 的 JS 代码、页面文件、静态资源等。通过配套的plus.runtime.install接口,可以将 wgt 包安装到设备上并覆盖旧资源。在一开始学习时,我对 wgt 包和整包 APK 的区别不够清晰,以为热更新可以解决一切问题,直到我尝试通过 wgt 包去替换一个原生插件相关功能时才发现:wgt 包根本替换不了原生层代码,必须另外发整包。这里就可以总结出一个边界表:
| 更新内容 | 热更新(wgt包) | 整包更新 |
|---|---|---|
| 页面结构调整 | 支持 | 支持 |
| JS 逻辑修改 | 支持 | 支持 |
| 静态图片、CSS 修改 | 支持 | 支持 |
| 新增依赖第三方 JS 库 | 支持(前提是在包内引入编译) | 支持 |
| 新增原生功能插件 | 不支持 | 支持 |
| 修改 App 图标、名称 | 不支持 | 支持 |
| 修改 Android 权限声明 | 不支持 | 支持 |
| 升级 Uniapp 基础库/SDK 版本 | 不支持 | 支持 |
这个表帮我避免了很多弯路。实践中的最佳判断标准是:如果你在修改过程中,完全没有动到manifest.json里的原生配置,也没有新增任何原生插件,那大概率可以用热更新解决;只要原生层有任何变化,就不要想省事,老老实实走整包更新流程。
另外要注意,wgt包更新是覆盖式的,也就是安装新 wgt 后,App 的启动会加载最新的资源。但这里有一个和管理体验相关的关键点:热更新下载安装的过程中,一定不能让用户在产品流程之外盲目操作。最安全的做法是,在检测到新版本后先给用户弹窗提示,得到确认后才进入下载安装流程;下载过程中要展示进度条;安装完成后最好自动重启 App,保证新版本立即生效。
5. 一套完整的热更新发布链路:版本号、下载器与更新策略
讲了热更新的边界和原理,接下来是把这条路真正走通的关键。我在做第一个热更新功能时,天真地以为只要把新的 wgt 包放到服务器上,让 App 去下载安装就行。结果实践证明,一个可以稳定运行的热更新系统,至少需要三张牌:可靠的版本号管理、稳定的包下载地址、清晰的更新策略。
首先说版本号管理。我更推荐的做法是:在 wgt 包里放置一个版本配置文件,内容一般是version和minVersion之类的字段。客户端每次启动时请求服务端接口,拿到当前线上最新版本号,再与本地的版本号比较。如果本地版本低于线上版本,就提示用户更新。这里有个细节容易被忽略:如果新版 wgt 依赖了比当前客户端版本更高的 Uniapp 基础库版本,那么热更新是不能直接覆盖的,必须先整包升级客户端,再走热更新。也就是说,版本管理不只要管理资源版本,还要管理客户端基础版本。
然后说下载地址。常见的做法是把生成的 wgt 文件放到 CDN 或者自己的静态服务器上,然后由服务端接口把下载链接返回给客户端。之所以强调用 CDN 而不是普通服务器,是因为当用户量较大时,大量用户同时下载同一个 wgt 包会对服务器带宽造成压力。另外,wgt 包的下载地址最好采用版本号作为路径区分,比如https://example.com/app-update/1.0.2.wgt,这样可以避免旧版本覆盖新版本的文件缓存问题。下载完成后,最好校验一下文件指纹,比如比对 MD5,防止下载过程中文件损坏。
接下来是更新策略。更新策略主要分三种:提示更新、静默更新、强制更新。提示更新适合功能性版本:弹窗问用户“发现新版本,是否立即更新”,让用户自己选择;静默更新适合小修补:直接后台下载,下次冷启动时自动安装,不过这种策略要谨慎使用,因为用户如果正在流量环境,突然消耗了几十 MB 流量,体验不会很好;强制更新适合有严重 Bug 或安全漏洞的情况,客户端检测到当前版本小于minVersion时,直接弹出一个不可关闭的升级提示,用户必须更新后才能继续使用。
具体到代码实现,核心逻辑其实不复杂。先通过接口拿到线上版本信息,与本地版本比对后,如果是需要更新的场景,就通过uni.downloadFile下载 wgt 包,下载完成后调用plus.runtime.install安装,安装完成后用plus.runtime.restart重启应用。我写了一个比较完整的判断流程,学习时可以参考:
// 更新检查逻辑示例(非完整工程代码,仅示意流程) function checkUpdate() { uni.request({ url: 'https://api.yourapp.com/checkUpdate', success: (res) => { const remote = res.data.data; // { version: '1.0.2', minVersion: '1.0.0', url: 'https://...', md5: '...', desc: '...' } const localVersion = getAppVersion(); // 获取本地资源包版本 if (compareVersions(remote.version, localVersion) > 0) { if (compareVersions(remote.minVersion, getClientBaseVersion()) > 0) { // 需要整包更新,跳转应用商店或下载页 uni.showModal({ title: '需要升级客户端', content: remote.desc || '检测到你当前的应用版本过低,请前往应用商店升级。', showCancel: false, }); return; } // 走热更新 downloadWgt(remote.url, remote.md5); } }, }); } function downloadWgt(url, expectMd5) { uni.showLoading({ title: '正在下载更新...' }); uni.downloadFile({ url: url, success: (res) => { if (res.statusCode === 200) { // 如有需要,在这里校验文件的 MD5 plus.runtime.install(res.tempFilePath, {}, () => { uni.hideLoading(); plus.runtime.restart(); // 重启后新的资源包生效 }, (err) => { uni.hideLoading(); console.error('install failed', err); }); } else { uni.hideLoading(); uni.showToast({ title: '下载失败', icon: 'none' }); } }, fail: () => { uni.hideLoading(); uni.showToast({ title: '下载失败', icon: 'none' }); }, }); }这段代码已经在几个模拟项目中实践过,可以作为初学者理解热更新链路的主干逻辑。但正式生产环境还需要补充几个细节:下载过程中要监听onProgressUpdate展示进度条;安装失败时要根据错误码提示用户“请重新下载”,避免一直卡在失败状态;强制更新场景下要增加重试按钮,不能让用户进退两难。
最后还要聊一聊回退策略。线上环境最怕的是新版本引入了更严重的 Bug,结果热更新一发布,所有用户的 App 都“更新了个寂寞”。所以服务端版本判断逻辑一定要设计成可回退的:如果线上版本号设置为 1.0.2,但 1.0.2 有问题,我们需要把线上版本号改回 1.0.1。这要求客户端的版本比较逻辑必须支持“降级”判断,只在本地版本低于线上版本时提示更新,而不强制要求只能升不能降。我在实际操作中发现,很多新手在写比较逻辑时,默认认为新版本号一定比旧版本大,等遇到回退需求时才发现代码根本不支持,这点很值得提前预防。
热更新和消息推送表面上不相关,但它们在实际项目里常常被同时提及,原因是它们都属于“App 发布后仍需要持续运营的能力”:推送是主动触达用户的管道,热更新是快速修复和迭代内容的手段。学会了这两个能力,独立维护一个跨端 App 的运营底气才会真正上来。我再分享一个学习建议:学习这类能力,不用一开始就追求把全部代码背下来,先把原理链路搞清楚,再照着上面步骤在实验工程里走一遍,遇到问题再针对性查文档,理解速度远比死记硬背要快得多。