先说结论:用 UniApp 做同城 O2O 项目,最舒服的不是“能一套代码跑三端”这个噱头,而是它把“独立部署”和“二次开发”这两件事的门槛压得很低。我去年到今年一直在维护一套跑在同城配送场景的 UniApp 项目,从微信小程序、H5 到 Android/iOS App 都在跑,期间经历了完整的上架、支付联调、地图接入、扫码、视频播放、WebView 混合通信这些环节。今天这篇就把这套项目里“独立部署”和“二次开发”涉及到的关键节点全部拆开讲,包括 manifest 配置、多域名切换、支付差异、扫码体验、地图适配、组件样式穿透、底部导航闪烁、麦克风权限这类高频问题,尽量做到你拿着就能用。
这篇东西适合谁?适合已经会一点 Vue 但还是第一次用 UniApp 接真实 O2O 业务的前端,也适合后端同学想自己把管理端和用户端部署起来做个闭环演示。我会尽量避免只讲 API 怎么调,重点讲这些 API 在真实场景里为什么会这样设计、有哪些坑你必须提前知道。毕竟同城 O2O 这种项目,核心不是页面多好看,而是“定位、支付、订单流转、消息触达”这条链路不能断。
1. 独立部署的整体思路与工程选型
1.1 为什么同城 O2O 更适合独立部署
很多人第一次接触 UniApp 时,习惯把它理解成“小程序工具”,实际在落地同城 O2O 项目时,你会发现业务方往往同时要求“小程序先上线,H5 也要能打开,App 后续也要有”。这时候如果依赖第三方 SaaS 平台或者云开发,业务数据、用户体系、支付商户号都会被平台绑死,后续想改业务逻辑非常难受。
独立部署的意思是:前端代码打包成各端产物之后,静态资源放在自己的服务器或 CDN 上,后端接口完全由自己控制,域名、证书、数据库、对象存储全部自己管。这么做的直接好处有三个:
- 数据主权在自己手里,用户订单、支付流水、配送轨迹这类敏感数据不会被第三方平台约束。
- 二次开发空间大,从首页装修、营销活动到配送路由算法,都可以直接改代码而不是等平台开放接口。
- 成本可控,初期可以只租一台低配云服务器跑后端,静态资源放 CDN,后续再逐步扩容。
但独立部署也意味着所有问题都得自己扛。尤其是 H5 端的域名校验、小程序的 request 合法域名配置、App 端的打包证书,这些环节环环相扣,任何一个配置不对,线上就直接白屏。后面我会把每一步都列出来。
1.2 技术选型:为什么是 UniApp 而不是原生或纯 Web
我承认原生小程序和纯 Vue Web 也都能做,但同城 O2O 这种“一鱼三吃”的业务形态下,UniApp 的性价比确实最高,原因是它把三个关键痛点在编译期就处理掉了:
第一,多端条件编译。同一套代码,通过#ifdef MP-WEIXIN、#ifdef H5、#ifdef APP-PLUS可以写出差异化的平台逻辑。比如 App 端支付需要调用 plus.payment,小程序端用 uni.requestPayment,H5 端则走 JSSDK 或者后端跳转,这些差异写在同一个文件里,维护成本比维护三套工程低得多。
第二,组件生态和原生能力的桥接。UniApp 提供了大量 uni.* API,像 uni.getLocation、uni.scanCode、uni.login 这些跨端能力,底层会自动抹平差异。O2O 项目里最频繁用到的就是定位和扫码,实测下来,小程序端和 App 端的表现虽然还有细微差别,但业务可接受度很高。
第三,独立部署友好。UniApp 的 H5 构建产物就是纯静态资源,随便扔到 Nginx 就能跑,不需要特殊运行时。小程序端发布到微信公众平台,App 端打好包上传到应用市场,全部流程都是标准化的。
当然,选 UniApp 也要接受它的限制。比如视频播放性能不如原生,复杂动画流畅度一般,还有个别原生插件需要自己写离线打包。我在项目里就碰到过“App 端录屏防止”这类需求,纯 UniApp 层做不到系统级,最后是靠原生插件才搞定。所以选型时不要盲目追求“一套代码走天下”,而是要在熟悉它边界的前提下,把优势用足,把劣势绕开。
1.3 项目目录设计与多端产物规划
同城 O2O 项目一般涉及用户端、骑手端、商户端三个角色,如果全部塞进一个 UniApp 工程,pages.json 会膨胀到没法维护。我目前用的是“一个主工程 + 多套页面分包”的方式,结构大致是这样:
src/ ├── pages/ # 用户端主包页面 ├── pages_merchant/ # 商户端分包 ├── pages_rider/ # 骑手端分包 ├── components/ # 公共组件 ├── api/ # 接口请求统一封装 ├── store/ # Pinia/Vuex 状态管理 ├── utils/ # 工具函数 └── static/ # 静态资源pages.json 里通过 subPackages 配置分包,每个角色包独立成包,首屏加载只下载主包。独立部署时,每个角色其实对应不同的登录态和权限,所以路由拦截也要分开写。这个设计一开始多花了半天规划,后面省了非常多反复改 pages.json 的时间。
产物规划方面,我是这样约定的:H5 端构建到dist/build/h5,小程序端用微信开发者工具导入dist/build/mp-weixin,App 云打包时直接选发行 > 原生App-云打包。每次发版前,固定跑一遍三端构建,保证产物都能正常出。注意分支管理:开发分支打测试包,master 分支打正式包,再用 CI/CD 自动推送 H5 到服务器,这是独立部署比较舒服的一条流水线。
2. manifest 配置与多域名适配
2.1 manifest.json 里最容易忽略的配置项
manifest.json 是 UniApp 项目的门面,除了应用名称、AppID、版本号这些基本项,同城 O2O 项目里还有几个配置项直接影响上架和运行,必须逐个过一遍。
- uni-app 应用标识:微信小程序端需要在 mp-weixin 节点填上自己的 AppID,否则开发者工具里预览会报错。App 端在云打包时需要在 DCloud 开发者中心创建应用,拿到 appid 填回来。
- App 模块配置:O2O 项目几乎必选定位、地图、支付、推送、摄像头这几项。这里要注意,云打包时模块不是默认全开的,没勾选对应模块,调用相关 API 会直接失败。我第一次打包就是忘了勾选定位模块,结果在真机上经纬度一直是 0。
- H5 端跨域配置:devServer 的 proxy 可以解决本地开发跨域,但线上还是建议后端处理好 CORS,或者用 Nginx 反向代理
/api路径。这点不配好,H5 发布后接口全部报跨域,排查起来很头疼。 - App 图标与启动图:安卓应用市场上架时对图标、截图、隐私政策都有硬性要求,manifest 里的图标配置只是第一步,真正的合规检查在应用市场后台。
另外,同城项目里经常会出现“一套代码对应多个客户”的情况,每个客户的 appid、包名、推送证书都不一样,这时候建议用环境变量配合 manifest 的 condition 编译模式来管理,不要每次发版都手改 manifest。我见过不少团队就是因为这个环节靠人手改,结果把客户的微信小程序 AppID 给串了,发布到线上才发现,回滚成本非常高。所以独立部署到多个环境时,manifest 必须纳入版本管理,并且要在 CI 里用脚本动态生成或替换对应配置段。
2.2 多域名环境:本地、测试、生产的切换策略
同城 O2O 项目涉及的环境至少三个:本地开发环境、测试环境、生产环境。每个环境的接口域名、文件上传域名、WebSocket 地址都不一样。如果全部写死在代码里,每切换一次环境就要全局搜索替换,效率低且容易漏。
我的方案是建一个config/index.js,按环境导出配置对象:
// config/index.js const env = process.env.NODE_ENV || 'development' const config = { development: { baseUrl: 'https://dev-api.example.com', uploadUrl: 'https://dev-upload.example.com', wsUrl: 'wss://dev-ws.example.com' }, test: { baseUrl: 'https://test-api.example.com', uploadUrl: 'https://test-upload.example.com', wsUrl: 'wss://test-ws.example.com' }, production: { baseUrl: 'https://api.example.com', uploadUrl: 'https://upload.example.com', wsUrl: 'wss://ws.example.com' } } export default config[env]然后在请求封装里统一用config.baseUrl拼接,不要在页面里直接写https://开头的硬编码地址。这样本地开发、测试联调、生产发版各走各的域名,互不干扰。打包的时候通过uni build --mode test这种形式注入环境变量,实测比直接改 manifest 里的 h5.router.base 更合理,因为域名只跟接口有关,H5 的 base 路径是另一回事。
还有一个容易踩坑的地方:微信小程序端有“request 合法域名”校验,每次新增一个二级域名,都要去微信公众平台的后台配置。如果你们测试环境和生产环境域名不一样,记得把两个域名都加进去,否则测试环境一切正常,换到生产环境就白屏,原因只是域名没加白名单。App 端没有这个限制,但 H5 端如果要跨域请求,必须后端配合 CORS,或者本地开发时在 devServer 里配置 proxy。
2.3 H5 端两个域名的玩法:同城多商户隔离
搜索热词里有“uniapp 封装h5如何指向2个域名”,这在同城 O2O 里其实很常见。比如一个平台下有多个城市分站,每个分站绑定独立域名,但前端代码是同一套。用户访问杭州分站时,接口域名是 hangzhou-api.example.com,访问上海分站时,接口域名是 shanghai-api.example.com。这个需求本质上是“运行时动态决定接口域名”,而不是“打包时静态指定”。
我的做法是:在 H5 端启动时,用脚本读取当前 window.location.hostname,匹配出对应的城市编码和 API 域名,写入全局 store。这样用户打开哪个域名,API 就指向哪个域名对应的服务集群。后端只需在网关层按城市编码做数据隔离,前端不用维护多套代码。
实现上需要在 index.html 里加一小段同步脚本,否则白屏阶段请求就已经发出去了:
<script> (function () { var host = window.location.hostname var city = 'default' if (host.indexOf('hangzhou') > -1) city = 'hangzhou' if (host.indexOf('shanghai') > -1) city = 'shanghai' window.__CITY__ = city })() </script>然后在请求封装初始化时读取window.__CITY__,替换 baseUrl 的城市前缀。注意 App 端没有 window.location,所以要单独配置默认城市。这个方案处理完以后,一套 H5 代码同时服务多个城市域名,部署时只需要在不同 Nginx 配置里指向同一份静态资源即可,非常省事。
3. 工程级特性与二次开发实战
3.1 ESLint 与代码规范:多端项目必须从第一天做起
UniApp 项目默认生成出来的代码风格比较自由,变量命名、组件导入、样式写法都没有强约束。但一旦进入二次开发阶段,多人协作的问题就暴露了:有人用分号、有人不用,有人写var有人写const,有人把业务请求直接写在页面里,有人封装到 api 目录。这些混乱在 O2O 这种长生命周期项目里会滚雪球,我后来花了两个晚上把所有文件过了一遍 ESLint,才把局面稳定下来。
具体操作是:在项目根目录安装eslint、eslint-plugin-vue,新建.eslintrc.js:
module.exports = { root: true, env: { browser: true, node: true, es6: true }, extends: ['plugin:vue/recommended', 'eslint:recommended'], parserOptions: { ecmaVersion: 2020, sourceType: 'module' }, rules: { 'vue/max-attributes-per-line': 'off', 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }] } }配套的.prettierrc建议统一 2 空格缩进、单引号、行尾不加分号。HBuilderX 内置的格式化工具和 Prettier 偶尔会打架,可以直接用命令行npm run lint强制校验。注意 UniApp 项目开启 ESLint 后,HBuilderX 里要确保工具链用的是项目本地依赖,而不是内置的旧版本,否则提示规则对不上。
还有个坑是:UniApp 的模板里会包含大量平台条件编译注释,ESLint 默认解析器可能不认识#ifdef这种写法,需要把常见平台指令作为注释处理。实测下来 ESLint 不会报错,但编辑器偶尔会把#ifdef高亮成错误语法,不影响编译但有点烦,可以在 EditorConfig 里把 .vue 文件的注释配置调整一下。
3.2 生命周期适配:页面、应用、组件三层
UniApp 的生命周期看起来跟 Vue 很像,实际上有三层需要分清:应用生命周期(App.vue)、页面生命周期(onLoad、onShow、onHide)、组件生命周期(Vue 标准生命周期)。同城 O2O 项目里最容易出问题的就是页面销毁和重新进入时的状态恢复,尤其是订单列表页、地图选点页这种带“当前状态”的页面。
举一个实际例子:用户在首页点击一个商品,进入详情页,然后返回首页,首页需要刷新购物车角标;用户从订单详情页点“再来一单”,回到首页后首页榜单数据需要重新拉取。如果只在 onLoad 里请求数据,页面从页面栈恢复时不会触发 onLoad,数据就停留在旧状态。所以我一般在首页用 onShow 配合一个 lastUpdateTime 字段,只在必要时强制刷新:
onShow() { if (Date.now() - this.lastUpdateTime > 30000) { this.fetchHomeData() } }App.vue 的 onLaunch 适合做全局登录态检查、版本更新提示、全局异常监听。注意小程序端 App.vue 里不能访问 DOM,也不能使用uni.setStorageSync之外的同步存储做太重的事情,否则首屏会有卡顿感。
组件生命周期则完全遵循 Vue 的 created、mounted、beforeDestroy,但要注意 UniApp 在 App 端和 H5 端的组件渲染时机略有差异。特别是地图组件、视频组件这类原生组件,mounted 里立刻去操作地图实例可能会拿不到,最好配合 nextTick 或者延迟 100ms 再执行。
3.3 微信小程序、H5、App 三端的支付流程差异
支付是同城 O2O 的核心环节,也是跨端差异最大的地方。搜索热词里有人问“uniapp打包app支付和微信小程序支付时支付流程和参数是否相同”,答案很明显:有差别,而且差别很大。如果不搞清楚差异点,服务端很容易设计出一套“万能支付接口”,但实际各端根本无法复用。
微信小程序走的是uni.requestPayment,参数里需要 provider: 'wxpay',订单信息由后端调用微信支付接口生成 prepay_id,然后经过签名后返回给前端 timeStamp、nonceStr、package、signType、paySign 这些字段,前端直接透传给 requestPayment 就行。
App 端也调用uni.requestPayment,但 provider 可以填 'wxpay',且依赖 manifest 里配置的微信支付模块和开放平台的应用。这里不同的点在于:App 端调起微信支付时,需要先在微信开放平台创建移动应用并绑定包名和签名,否则真机上会提示“微信未安装”或者调不起支付。整个流程里,后端统一下单时使用的 appid 必须是开放平台应用的 appid,不能拿小程序 AppID 去下单,否则 App 端支付会失败。
H5 端的支付就相对麻烦。微信 H5 支付需要后端返回一个 mweb_url 链接,前端用window.location.href跳转过去,支付完成后再跳回回调地址。这个回调地址需要和后端约定好,一般是window.location.origin + '/#/pages/order/detail?id=xxx'这种带参数的路由,否则支付完成后用户无法回到正确的订单页。另外 H5 支付只能在微信浏览器里用微信支付,普通浏览器里得用支付宝或者拉起重定向。我的建议是 H5 端把微信支付和支付宝都接上,根据uni.getSystemInfoSync().platform判断当前浏览器环境,再决定用哪种支付方式。
支付回调后的订单状态更新,前端不要信任自己的业务逻辑判断,一切以后端回调为准。前端只负责根据支付结果跳转到“支付成功”或“支付失败”页面,页面上要提供“刷新订单状态”按钮,避免用户卡在中间态。
3.4 自定义分享:小程序好友分享与 App 分享
同城 O2O 的拉新裂变场景里,自定义分享是刚需。小程序端可以用uni.showShareMenu和onShareAppMessage,最常用的是把商品的标题、图片、路径分享给微信好友。这里有一个细节:分享出来的卡片要能正确打开对应商品详情,路径里的参数必须经过 encodeURIComponent 处理,商品标题里有特殊字符时尤其要注意。
onShareAppMessage() { return { title: this.goods.name, path: `/pages/goods/detail?id=${this.goods.id}&from=share`, imageUrl: this.goods.cover } }App 端分享跟小程序完全不一样,需要调用 plus.share 模块,而且要预先在 manifest 里配置分享 SDK。微信分享需要申请微信开放平台并配置 Universal Links(iOS)和包名签名(Android)。做完这些之后,代码里通过uni.share这个封装可以统一调用,但底层是否配置成功,必须在真机上用微信小号实测,开发者工具里模拟不了。
H5 端的分享就比较弱,微信内置浏览器可以通过微信 JS-SDK 自定义分享标题和缩略图,普通浏览器只能靠复制链接或生成海报。我给项目加了一个“生成分享海报”的通用组件,用 canvas 绘制商品图、价格、二维码,用户保存图片后再去微信/朋友圈发布,这种“图片分享”的方式在 O2O 场景里比纯链接转化率高很多。
3.5 扫码能力:从商品码到门店码再到一码多扫
同城项目里的扫码场景特别多:扫码核销优惠券、扫码开门、扫码骑车上锁、扫码点餐。UniApp 扫一扫用的是uni.scanCode,小程序端会自动拉起微信的扫码界面,App 端则是调用系统相机扫码。如果只是扫普通二维码,这套 API 完全够用,但同城项目里经常会遇到“扫码不清”的问题,尤其是骑手在户外扫车身码、门店码,光线暗或者码面破损时识别率下降明显。
这时有几个实操经验可以分享:
- 扫码前检查相机权限,被拒绝时要引导用户去系统设置开启权限。
- 如果业务码是固定的,像门店码这种长期不变的,可以在服务端生成高清码并统一尺寸,不要用 App 动态生成的低码率二维码去打印。
- 如果扫出来的内容不是 URL 而是纯业务字符串,比如
o2o://shop/1001?token=xxx,原生识别器也能识别,但要注意内容里的特殊字符,建议二维码内容用 URL 编码后再生成,否则某些扫码引擎会截断。
uni.scanCode在 App 端有一个需要注意的问题:扫码成功后回调里的 result 字段,在部分安卓机型上取到的不是完整二维码内容,而是自动跳转后的 URL。这是因为一些 ROM 的扫码组件会自作主张地对二维码内容做识别并跳转。解决办法是不要直接在扫码回调里做业务逻辑,把结果带回页面后先正则校验,不符合预期格式就给用户一个“无法识别”的提示,不要静默失败。
另外,小程序端的扫码结果如果是普通链接,微信会默认跳转而非返回 result。这个时候可以在 scanCode 成功回调里用resultStr判断,如果是以 http 开头的内容,就复制到剪贴板并提示用户自行打开,或者直接中转到一个 H5 解码页再带回小程序。
3.6 天地图接入:微信小程序、H5、App 三端处理
同城 O2O 项目里地图选点和轨迹展示非常核心,但国内商用地图对小程序端的支持一直不算顺畅。热词里提到的“uniapp接入天地图适配微信小程序、h5、app”,其实是对接中国地图服务商的地图能力做统一封装。天地图的好处是合规且免费额度相对充足,但它的 JS API 也是标准的 Web API,在小程序里不能直接用,需要通过 WebView 嵌入 H5 页面来加载。
我的做法是:写一个 MapContainer 组件,内部用 WebView 承载一个 H5 地图页面。页面之间通过 postMessage 通信,小程序的<web-view>和 App 里的plus.webview都支持向内部页面发消息。地图页面包装了天地图 JS API,支持搜索、选点、逆地理编码、轨迹回放,对外暴露统一接口给 UniApp 层调用。
这个方案在 H5 端最省心,因为本身就是网页,直接引入天地图 JS API 即可。小程序端受限于域名校验,要求 web-view 承载的 H5 页面域名必须配置为业务域名。App 端限制相对少,但要处理 webview 返回时的页面栈问题。
天地图定位精度其实还可以,但要把它跟手机原生定位结合:先用uni.getLocation拿到经纬度,再把经纬度传给地图组件去反查地址。不要指望地图组件自己去定位,因为 WebView 里的浏览器定位权限在各端上的策略不一样,精度和成功率都不稳定。
3.7 视频播放与展示:商品展示视频的自动播放和预加载
同城 O2O 的很多商品,比如美甲、宠物、家厨这类服务型商品,文字描述很难打动人,视频的重要性很高。UniApp 里视频播放用的是video组件,基础用法不复杂,但 O2O 场景里有两个需求会用到:视频懒加载和自动播放。热词里提到“uniapp 商品展示视频”“uniapp视频自动播放”“uniapp视频预播放”,都是围绕这几点展开的。
小程序端和 App 端对自动播放的限制不一样:小程序端 iOS 上 video 组件要用户手动触发才能带声音播放,Android 上静音自动播放相对宽松;App 端用 plus.video 封装可以实现更多控制。我的方案是:列表页的视频全部用图片盖层模拟,用户滑动到视口内时,再动态设置 video 的 src 并调用 play 方法。避免一次性加载大量视频地址,否则内存占用会直接拉爆中低端安卓机。
预加载方面,video 组件本身没有预加载 API,但可以在用户即将看到视频前先渲染一个隐藏的 video 并设置preload="auto"。这里要注意,小程序端的 preload 属性目前支持仍有限,所以更可靠的做法是只设置 poster,让系统在进入页面时自动拉取第一帧,实际播放时再加载完整视频源。不要在小程序里对 video 做太激进的预加载策略,微信对这类操作有性能审计,严重的会有告警。
H5 端相对自由一点,可以在列表滚动时用 IntersectionObserver 判断视频进入视口,再动态创建 video 元素播放。但每个网页同时播放多个视频会互相抢音频焦点,所以要注意同时只保留一个活动播放器,其余全部 pause。
4. 高频问题排查与避坑实录
4.1 WebView 返回处理与 H5 重新加载
UniApp 里的 WebView 返回行为,跟普通页面的 uni.navigateBack 不是一回事。热词里专门有人问“uniapp webview的页面返回方式跟常规页面返回不太一样怎么处理”,我深有同感。因为 WebView 内部有自己的历史栈,用户在内嵌 H5 页面里点返回,通常应该优先返回 H5 的上一个历史记录,而不是直接退出整个 WebView 页面。
我封装的返回策略是这样的:在 WebView 页面里用plus.webview.currentWebview()拿到原生 webview 对象,监听 onBackButton,然后调用canBack()判断内部是否有历史记录。如果有,执行back();如果没有,再执行uni.navigateBack。
小程序端用<web-view>的时候,情况比较简单,小程序菜单里自带的“返回”按钮会直接退出 web-view 页面,H5 内部的历史路由没法接管。所以小程序端的内嵌 H5 页面必须自己处理好单页路由,保证用户从 H5 的任何子页面返回时都能退到“退出 H5”这个入口。否则用户会卡死在 H5 内部,体验非常差。
另外,H5 端重新加载当前页面也是一个常见需求。支付完成或者登录态变化后,经常需要清理页面状态。普通 Vue 页面里可以用uni.redirectTo重定向到当前页,但在 WebView 页面里不行,最好让内嵌 H5 监听 postMessage 收到 refresh 事件后执行location.reload()。我在前端请求拦截器里加了一个forceRefresh标记,登录过期时自动通知 WebView 刷新页面,实测能解决很多“为什么我登录了还显示未登录”的诡异 bug。
4.2 底部导航栏切换闪烁处理
如果你用 uni-app 的 tabBar 管理 O2O 项目的首页、订单、我的这些主模块,大概率会遇到切换 tab 时底部导航闪烁一下的问题。我在多个机型上复现过这个问题,原因是 TabBar 在切换时页面进入动画和新页面资源加载同时进行,底栏被重绘,视觉上就像闪了一下。
我的解决思路分为两个层面:
第一,减少页面白屏时间。每个 tab 页的首屏请求尽量做缓存,比如首页的 banners、分类、推荐商品列表,可以缓存到 storage,设置 5 分钟过期。用户切换 tab 时先渲染缓存数据,再后台刷新新数据,视觉上就不会有一闪而过的白屏感。
第二,对 TabBar 的 icon 资源做特殊处理。尽量控制 icon 文件体积,不要用几 MB 的大图,推荐用 81x81px 的 PNG,压缩后单张在 20KB 左右。icon 加载过慢也会导致 tabBar 重绘时闪烁。这个问题在高分辨率安卓机上尤其明显。
如果闪烁已经影响了正常的开发调试,可以先开uniapp的--watch模式看是编译导致的界面重载还是运行时导致的,如果快速切换 tab 时 console 里有大量 WebView 重建日志,那大概率是 App 端在复用一个 webview 容器时处理不当,可以尝试关闭“切换动画”或者把 tabBar 的animation设置为 false。
4.3 安卓手机打包后缺少麦克风权限
这是一个典型的“云打包后权限清单发生变化”的问题。很多 O2O 项目需要语音输入、语音通话或者录音评价,本地调试时 uni.getRecorderManager 一切正常,但打包安装到小米手机上,设置里根本没有麦克风权限选项,导致录音完全没声音。
原因在于 UniApp 云打包时,原生权限是按模块动态生成的。如果你的工程里没有实际调用录音相关 API,或者对应的原生插件没有声明麦克风权限,云打包时就不会把 RECORD_AUDIO 加进 AndroidManifest.xml。解决办法是在 manifest.json 的“App 模块配置”里手动勾选“Recorder(录音)”模块,有的版本还需要在“权限配置”里显式添加android.permission.RECORD_AUDIO。
这里有一个很容易忽略的细节:如果 App 端只是引入了录音组件但从未调用,云打包的时候树摇优化可能把相关代码当成无用代码剔除,权限也就不会被识别。建议在代码里加一个权限检查函数,打包前先在真机上跑一次最小录音流程,再确认权限申请弹窗是否正常。如果还是没有权限,就要考虑用自定义基座调试,而不是用标准基座,因为标准基座包含的权限集合跟自定义基座不一定完全一致。
另外,权限申请时机也很重要。不要在 App 启动时一次性申请所有权限,用户会反感和拒绝。按功能模块分步申请,比如用户点“录音评价”时才申请麦克风权限,点“扫码”时才申请相机权限。App 端可以用 plus.android.requestPermissions 主动申请,参数里要带上权限说明,说明写得越具体,用户接受率越高。
4.4 弹出层打开时底部页面滚动穿透
同城项目里经常用弹出层展示地址选择、优惠券、服务说明。在 H5 和小程序里,弹层打开时底部页面依然可以滚动,这就是“滚动穿透”。解决办法网上很多,常规的是在弹层打开时给页面加overflow: hidden,但小程序里页面根节点是 page,这个方案有时不生效。
我后来用了一招比较稳的:弹层打开时在页面最外层容器上加一个 class,设置height: 100vh; overflow: hidden,弹层关闭时移除这个 class。注意在小程序里要把page的高度设置为100%,否则100vh在部分安卓微信版本里会有底部安全区计算问题。
App 端如果遇到穿透,可以直接用plus.navigator.setStatusBarStyle配合原生层拦截,或者把弹层做成半透明遮罩并监听 touchmove 事件执行 preventDefault。但要注意,不要给 document 全局绑定 touchmove preventDefault,这次会同时封杀掉弹层内部的滚动,比如优惠券列表就没法滑了。正确做法是只在遮罩层上监听,用事件来源判断是否允许滚动。
一个容易踩的坑:Vue 的 transition 动画会使遮罩层的 touchmove 监听在动画结束后才生效,所以动画期间底部页面可能会“抖一下”。我的经验是把 touchmove 的监听放在打开弹层的同一帧就绑定,而不是等 transition 的 afterEnter 回调再绑定。
4.5 父子组件样式穿透与 scoped 处理
UniApp 的 Vue 单文件组件默认支持 scoped 样式,但它跟普通 Web 项目一样,遇到需要修改子组件内部样式时会受限于 scoped。同城项目里最典型的是第三方组件库的样式定制,比如要给一个城市选择弹层改主题色,或者给支付面板调整按钮圆角。
常规做法是使用::v-deep别名deep选择器。但 UniApp 在不同编译端对深度选择器的支持程度不一样,H5 端和小程序端表现良好,App 端在 Vue2 版本里偶尔需要写成/deep/。我觉得最简单的规避方式:尽量给子组件预留样式变量或 className 插槽,不要在父组件里强行穿透三层。比如自己开发的组件,props 里加一个customClass,组件根节点拼接这个类,这样既优雅又不会因为编译平台不同导致样式失效。
如果必须穿透,可以这样写:
<style lang="scss" scoped> .parent-class :deep(.child-class) { color: #ff6600; } </style>注意:deep()括号里不要带父级前缀,否则编译到小程序端会出现层级过深的问题。另外,H5 端在部分浏览器上:deep()编译出来的选择器优先级不够,必要时可以通过!important兜底,但不要滥用。样式穿透是典型的“能不用就不用”的方案,能用组件属性就优先组件属性。
4.6 sqlite、本地缓存与离线能力
O2O 项目对网络依赖很高,但用户在地下停车场、地铁里经常断网。同城骑手端和配送端尤其需要离线能力,热词里提到“uniapp sqlite”,这更多出现在 App 场景。UniApp 的 App 端可以在 plus.sqlite 的 API 上封装一些轻量本地存储,但说实话,对于大多数中小型 O2O 项目,用uni.setStorageSync配合 JSON 存储就够了。
我在骑手端做过一个“离线订单缓存”的功能:骑士接单后,把订单的核心字段(订单号、取货地址、配送地址、预计送达时间)写入本地 storage。网络断开时,骑手仍然能查看这些订单,点“拨打电话”或者“上报异常”时先入本地队列,网络恢复后统一上报。这里不能用 sqlite,因为 SQLite 的 API 在 UniApp 里属于 plus 扩展,H5 端没对应能力。用 storage 做 KV 存储配合队列发送,全端兼容,逻辑还更简单。
小程序端有 wx.setStorage 的大小限制,单个 key 最大 1MB,但同城项目的订单列表对象通常只有几十 KB,完全够用。只要注意不要把一个 list 数组无限追加塞进同一个 key 就行,按订单 ID 拆分存储,定期清理过期数据。这个方案在 H5 端也可以直接用 localStorage,行为一致。
当然,如果你要处理的数据量真的很大,比如骑手一天几千条轨迹点,那么存储格式就得换。此时建议把轨迹点先聚合成批量数据,每分钟或每十秒写入一次 storage,而不是每秒钟写一次。轨迹压缩做一层抽稀,只保留关键拐点,否则用户的手机存储会被日志吃空,这也是很多地图轨迹组件容易忽略的性能问题。
4.7 小米等安卓应用市场上架的合规要点
上架安卓应用市场是不少新手最容易卡壳的一环。你以为云打包出来一个 APK 就能上传,实际上各应用市场对隐私政策、权限声明、备案要求都有严格规定。同城 O2O 项目必然会申请定位、相机、存储、麦克风权限,这些都属于敏感权限,应用市场要求你提供真实的隐私政策地址,并且在 App 内展示和可撤回。
我踩过的坑主要有三个:
- 应用市场要求“隐私政策弹窗”必须在 App 首次启动时展示,用户同意后才能进入主界面和调用相关系统能力。所以 App.vue 的 onLaunch 里不要直接去获取定位或申请权限,先弹隐私政策授权窗,用户点了同意再初始化地图定位、推送、统计 SDK。
- 小米应用市场要求 App 必须具备“账号注销”功能,入口要清晰,注销流程要真的能走通,不能只放一个客服电话。
- 如果 App 里接了支付宝或微信支付,要确保支付功能在审核期间可以被测试到,否则审核人员可能以“功能不可用”为由拒绝。我一般会在隐私政策弹窗之后加一个“游客浏览模式”,允许审核人员先看到核心页面,而不是被登录页挡死。
这些合规细节跟代码逻辑无关,但完全决定你的包能不能过审。独立部署到各个安卓市场时,要针对每个市场的规范做差异化调整。应用市场审核有时就像开盲盒,但基本要求前置做足,后面就能节省大量来回修改的时间。
5. 打包发布与持续交付经验
5.1 H5 部署:Nginx 配置、history 路由与缓存策略
H5 打包产物部署到 Nginx,有几个细节必须处理。首先是h5.router.base配置,如果你的站点部署在根目录,base 设为/;如果部署在/app/子路径,则需要设置为/app/,否则打包出来的 index.html 引用 JS/CSS 的路径全部是绝对路径,导致 404。
路由模式建议用 history,而不是默认的 hash。history 模式 URL 更干净,但需要 Nginx 做 try_files 配置:
location / { root /var/www/o2o-h5; index index.html; try_files $uri $uri/ /index.html; }否则用户直接在浏览器地址栏访问/pages/order/detail,刷新后会 404。这是 H5 独立部署最常遇到的白屏原因。
静态资源缓存策略也很关键。同城业务迭代快,活动页经常改,不合理的缓存策略会导致用户永远看到老版本。我的做法是给带 hash 的资源(JS/CSS)设置Cache-Control: max-age=31536000, immutable,给 index.html 设置Cache-Control: no-cache,确保每次发版后用户打开首页立即拉到最新入口文件。Nginx 配置片段:
location /static/ { expires 1y; add_header Cache-Control "public, immutable"; }5.2 小程序发布与版本管理
微信小程序的发布流程很简单,但版本管理需要严谨。开发者工具导入dist/build/mp-weixin后,要做的第一件事不是预览,而是先确认manifest.json里的微信小程序 AppID 是否正确。提交代码前,跑一遍npm run build:mp-weixin,确保产物是最新的,否则就会出现“开发工具里代码改对了,上传包还是旧代码”的笑话。
小程序的体验版、审核版、线上版是三个独立的版本。同城项目一般会先在体验版上放给业务同事验收,然后再提交审核。审核期间线上版本保持不变,所以小程序线上出问题,回滚方案是重新提交一个旧版本代码包,而不是像 H5 那样直接改服务器静态资源。这个回滚流程要提前跟后端约定好,线上问题的定位优先级也要和后端分开,否则两边互相等,事故时间就被拉长了。
5.3 App 云打包与自定义基座调试
UniApp 的云打包很方便,但调试阶段强烈建议使用“自定义调试基座”。原因很简单:自定义基座包含了你选择的全部原生模块(地图、支付、推送、录音等),真机运行时可以直接调用这些原生能力。如果只依赖标准基座,部分模块不可用,导致“运行时 undefined”的情况。
云打包正式包时,注意证书的选择。Android 用 keystore 签名,iOS 用 p12 证书和描述文件。证书的别名和密码必须记录好,忘了密码只能重新生成证书,但重新生成证书后,已经在应用市场上架的老应用无法直接覆盖安装,需要做升级适配。这个坑我见过不止一次,一定要把证书文件纳入团队密码管理。
另外,云打包时“广告标识”和“推送通道”这些配置,如果不是业务必须,建议不要勾选,否则打包产物会变大,还可能会收到平台推送 SDK 的合规提醒。O2O 项目如果需要消息推送,我建议优先使用厂商通道(小米、华为、OPPO、vivo)加个自建推送服务的组合,而不是依赖打包平台内置的推送,这样在离线送达率和自定义消息上更可控。
结尾:一点实际体感
从项目启动到现在,我最大体感是:UniApp 的“独立部署”和“二次开发”能力,在同城 O2O 这个品类里其实是超出预期的。小程序、H5、App 三端之间,真正需要大改的通常只是支付、地图、分享这类原生能力调用段,而业务逻辑、页面交互、接口封装这些核心代码几乎可以做到一套走完。但反过来说,UniApp 不会替你处理业务上的复杂状态,比如订单状态机、配送超时、骑手和用户的实时位置同步,这些还是得老老实实按照传统前端的工程规范来做。
还有一个小经验分享给后来者:在启动这种多端项目时,一定要先把 manifest 和 config 这套“环境基础设施”梳理清楚。我见过很多团队在项目初期图省事,域名写死在各个页面里,打包时靠全局替换来切换环境,结果到了多城市部署时彻底失控,回滚一次要花半天。如果你打算长期维护一套同城 O2O 系统,第一天就做好多环境配置管理,绝对是最值得花的时间。
最后再补充一个技巧:发布 H5 之前,务必检查一遍h5.router.base,以及静态资源路径里是否带上了 CDN 前缀。很多时候“换个域名部署就白屏”的问题根本不是代码问题,而是资源路径四舍五入写死了。把 base 和 CDN 路径抽象成环境变量之后,再遇到迁移服务器或者新增城市节点,你只需要改一行配置,剩下的交给构建流水线就好。