简介:面向希望把 Vue 页面接入移动端原生能力的 Web 前端开发者与跨平台入门者,这份 zip 压缩包围绕 Vue 与 Cordova 的集成展开,重点覆盖地理位置获取、手机振动、调用相机或相册图片以及二维码扫描等常见场景,并涉及插件配置、平台兼容性、权限请求与真机调试等实践问题。包体约 14.48MB,文件总数显示为 0,文件类型明细上游暂无数据,因而无法进一步拆分具体文件构成。已有 534 人学习或下载,说明该主题在 Vue 移动化与 Cordova 原生功能接入方面具有一定关注度。读者可从中了解多类原生能力在 Vue 组件中的组织方式,以及相机参数、定位精度与超时、扫码结果处理、振动调用等配置思路,并参考权限管理和 Android/iOS 运行调试相关内容,降低从 Web 页面到移动端原生功能联调时的试错成本。
1. 从 H5 到原生能力:Vue + Cordova 这套组合到底解决什么问题
在 Chrome 里写好扫码按钮,真机上一点就静悄悄——控制台没有任何报错,因为 WebView 里根本没有原生的扫码通道;在 iOS 上navigator.vibrate返回 undefined;navigator.geolocation在file://协议下直接被拒绝。这套 Vue 集成 Cordova 的 Native 功能方案,处理的就是这类落差:把获取地理位置、手机振动、调取手机图片、扫描二维码这四个高频能力,从 WebView 沙箱里接出来,挂到 Vue 组件的生命周期上。
它适合已经在用 Vue 写业务、但需要包一层壳进应用商店的中小团队,也适合想把 Cordova 插件桥接机制吃透、之后自己写自定义插件的人。定位、拍照、扫一扫这三件事看着简单,真机上翻车的姿势比 H5 多得多。
2. 工程骨架拆解:Vue 页面、Cordova 壳与插件三层的目录与依赖
很多人一上来就cordova create然后npm run build把 dist 拖进 www,跑起来发现白屏,或者插件全 undefined。问题几乎都出在没有分清这套工程的三层职责:Vue 是跑在 WebView 里的业务层,Cordova 是提供cordova.js和 exec 桥的容器层,插件才是真正落到 Android/iOS 原生代码的能力层。三层之间靠一个全局对象和一套回调约定通信,任何一层错位都会连锁失败。
2.1 三层结构与一次插件调用的完整链路
先看调用链。Vue 组件里写下navigator.camera.getPicture(success, fail, options)之后,实际发生的事比表面复杂:
Vue 组件 └─ navigator.camera.getPicture(...) // cordova-plugin-camera 注入到 window 的 JS 门面 └─ cordova.exec(success, fail, "Camera", "takePicture", [options]) └─ 平台桥(Android: exec.js + prompt 拦截 / iOS: iframe + scheme 拦截) └─ 原生插件类(CameraLauncher.java / CDVCamera.m) └─ 调系统相机 Intent / UIImagePickerController └─ 结果按 exec 的回调 id 原路返回这条链上有两个容易忽略的点。第一,navigator.camera不是浏览器原生对象,它是插件在deviceready事件触发后动态挂到 window 上的,所以在 Vue 的mounted里直接调大概率拿到 undefined。第二,cordova.exec的 success/fail 是一次性回调,不是 Promise,Cordova 内部用一个自增的callbackId维护映射表,插件返回时按 id 找回对应的函数。这意味着如果你在同一个页面并发调两次扫码,回调不会串,但你自己在业务层不做隔离就会串。
我一般会在项目里显式保留cordova.exec的直调能力,作为插件 JS 门面出问题时的兜底:
// 直接走 exec,绕过插件注入的 navigator.xxx 门面 // 参数含义:成功回调、失败回调、服务名(原生类注册名)、动作名、参数数组 cordova.exec( (result) => console.log('native result', result), (err) => console.warn('native error', err), 'Camera', // 对应原生插件里注册的 service 名 'takePicture', // 对应原生插件里的 action 名 [{ quality: 50 }] // 传给原生的参数数组,顺序由原生侧解析决定 );这段代码的价值不在于日常使用,而在于排查:当navigator.camera是 undefined 但页面明显已经deviceready时,用 exec 直调能立刻判断是「插件没装」还是「门面没注入」,把问题范围砍掉一半。服务名和动作名必须与插件原生侧plugin.xml里声明的完全一致,大小写敏感,写错了原生侧会抛Class not found,而这个错误在 Android 的 logcat 里比在控制台里清楚得多。
2.2 目录结构与构建产物落在哪
这套工程的标准形态是两个目录并存:一个是 Vue 的源码工程,一个是 Cordova 的壳工程。中间靠一次「拷贝构建产物」的动作连接。目录大概长这样:
project-root/ ├── vue-app/ # Vue 源码工程 │ ├── public/index.html # 必须引入 cordova.js │ ├── src/ │ │ ├── native/ # 原生能力封装层,全部插件调用集中在这 │ │ │ ├── index.js │ │ │ ├── geolocation.js │ │ │ ├── camera.js │ │ │ ├── scanner.js │ │ │ └── vibrate.js │ │ ├── views/ # 业务页面 │ │ └── main.js │ ├── vue.config.js # publicPath 必须为空字符串 │ └── package.json └── cordova-shell/ # Cordova 壳工程 ├── config.xml # 应用名、包名、权限、插件声明 ├── package.json # cordova platform / plugin 依赖 ├── www/ # Vue 的 dist 内容拷到这里 ├── platforms/ # 生成的 android / ios 原生工程,别手改 └── plugins/ # 安装后的插件源码关键在vue.config.js里的publicPath。Cordova 用file:///android_asset/www/index.html加载页面,绝对路径/js/app.js会被解析成文件系统根目录,直接 404 白屏:
// vue.config.js module.exports = { // Cordova 以 file:// 协议加载,公共路径必须是相对的空字符串 publicPath: '', // 关掉 source map 可以显著减小包体,但调试期建议开着 productionSourceMap: false, outputDir: 'dist', // 资源内联阈值调低,减少零碎小文件的 file:// 请求 chainWebpack: (config) => { config.module.rule('images').set('parser', { dataUrlCondition: { maxSize: 4096 } }); } };publicPath: ''是这套组合里最不能省的一行配置,写成'./'在多数场景也能跑,但配合 history 路由时会出现路径叠加问题,空字符串最稳。outputDir保持默认的dist,构建后把这个目录里的内容(不是目录本身)拷到cordova-shell/www,覆盖原有文件但保留cordova.js的引用位置——实际上现代 Cordova 是运行时由平台注入cordova.js,www里本来就没有这个文件,index.html里写<script src="cordova.js"></script>只是留个位置,找不到会 404 但不影响功能,这个 404 是正常的,别去修。
platforms/目录是cordova platform add时生成的原生工程,里面确实可以改代码,但每次cordova prepare都可能被覆盖。血泪经验是:所有原生侧改动要么写进config.xml,要么做成 hook 脚本,要么自己写插件,别直接改platforms/android/app/src/main/AndroidManifest.xml。
2.3 插件清单与 config.xml 的对应关系
四个功能对应的插件就这么几个,装的时候注意版本,cordova-plugin-camera从 5.x 开始对 Android 的 FileProvider 配置有要求,老项目升级容易在这里翻车:
| 能力 | 插件包名 | 关键调用入口 | 备注 |
|---|---|---|---|
| 获取地理位置 | cordova-plugin-geolocation | navigator.geolocation.getCurrentPosition | 复用标准 W3C 接口,迁移成本低 |
| 手机振动 | cordova-plugin-vibration | navigator.vibrate | iOS 只支持固定时长,不能自定节奏 |
| 调取手机图片/拍照 | cordova-plugin-camera | navigator.camera.getPicture | 返回 base64 或 file:// 路径,二选一 |
| 扫描二维码 | cordova-plugin-barcode-scanner | cordova.plugins.barcodeScanner.scan | 部分分支包名不同,注意区分 |
安装用统一命令,一次装齐:
cd cordova-shell # 平台先加,插件后装,顺序反了会因为缺 platform 而报错 cordova platform add android cordova platform add ios # --save 会把插件写进 config.xml 与 package.json,团队协作必须有 cordova plugin add cordova-plugin-geolocation --save cordova plugin add cordova-plugin-vibration --save cordova plugin add cordova-plugin-camera --save cordova plugin add cordova-plugin-barcode-scanner --save # 装完确认,输出里应能看到每个插件及其版本 cordova plugin list--save这个参数千万别省。不带它插件只装在本地plugins/目录,config.xml里没有记录,同事拉代码后cordova prepare出来的是个没有插件的空壳,然后开始互相怀疑人生。装完用cordova plugin list确认,输出里每一项都带版本号,这个清单直接决定了后面deviceready之后navigator上能挂出什么。
config.xml里除了插件自动写入的<plugin>节点,还需要手动确认三件事:<widget id>里的包名(上架后不能改)、<content src="index.html">指向正确、以及 Android 的权限声明。相机和定位的权限在新版插件里多数会自动写入AndroidManifest.xml,但 iOS 的Info.plist描述文案必须自己补,缺一个就是提交审核被拒或者运行时闪退:
<!-- config.xml 片段:iOS 权限描述,文案要写清用途 --> <platform name="ios"> <edit-config file="*-Info.plist" mode="merge" target="NSCameraUsageDescription"> <string>需要使用相机拍摄照片作为业务凭证</string> </edit-config> <edit-config file="*-Info.plist" mode="merge" target="NSPhotoLibraryUsageDescription"> <string>需要读取相册以选择已有图片</string> </edit-config> <edit-config file="*-Info.plist" mode="merge" target="NSLocationWhenInUseUsageDescription"> <string>需要获取当前位置用于签到定位</string> </edit-config> </platform>这三段文案不是走过场。iOS 系统弹权限框时会把这段文字原样展示给用户,写「需要权限」这种含糊描述在审核环节大概率被打回,写清楚具体业务用途才过得去。mode="merge"表示合并进已有的 plist 而不是覆盖,缺了它可能把整个 plist 冲掉。
3. 设备能力接入实战:定位、振动、相册、扫一扫四条链路
功能全部集中在src/native/这一层,业务页面不直接碰navigator.camera这类全局对象。这样做的好处是后面换成自研插件或者换成 Capacitor 时,只改这一层。下面四个模块的写法都是我在实际项目里跑过真机的版本,参数含义和踩坑点都标在注释里。
3.1 统一入口:deviceready 与 Promise 化封装
所有原生调用必须等deviceready。这个事件只会触发一次,且 Vue 的mounted很可能在它之前就跑完了——尤其是首屏渲染快的项目,这个时序问题在模拟器上不容易复现,一到低端真机就暴露。
// src/native/index.js let readyPromise = null; /** * 返回一个在 deviceready 后 resolve 的 Promise * 多次调用复用同一个 Promise,避免重复注册监听 */ export function whenReady() { if (readyPromise) return readyPromise; readyPromise = new Promise((resolve, reject) => { // 浏览器里跑的时候没有 cordova 对象,直接给个明确的失败信号 if (typeof cordova === 'undefined') { reject(new Error('NOT_IN_CORDOVA')); return; } // 已经就绪的情况要单独判断,否则会一直等一个不会再来一次的事件 if (window.cordova && window.cordova.platformId) { // 注意:platformId 存在不代表插件已注入,仅代表桥已建立 document.addEventListener('deviceready', () => resolve(), { once: true }); // 兜底:部分机型 deviceready 会晚于首个 tick,用 0ms 宏任务让出一次 setTimeout(() => resolve(), 0); } else { document.addEventListener('deviceready', () => resolve(), { once: true }); // 超时保护,8 秒没就绪基本可以判定为 cordova.js 没引入 setTimeout(() => reject(new Error('DEVICEREADY_TIMEOUT')), 8000); } }); return readyPromise; } /** * 把 Cordova 的一次性回调接口包成 Promise * @param {Function} fn 形如 (success, fail, ...args) => void 的插件函数 * @param {Array} args 传给插件的业务参数 */ export function promisify(fn, args = []) { return new Promise((resolve, reject) => { fn( (result) => resolve(result), (err) => reject(new Error(typeof err === 'string' ? err : err.message || 'NATIVE_ERROR')), ...args ); }); }whenReady里那段「已经就绪」的判断值得多说两句。cordova.platformId在桥建立后就有值,但插件门面(navigator.camera等)是在deviceready派发时才注入的,两者有一个极短的时间差。所以这里既注册监听又留了setTimeout(0)兜底,实际跑下来能覆盖绝大多数机型的时序抖动。8 秒超时那行是后悔药,专门治「忘了在 index.html 引 cordova.js」这个经典错误——没有它,页面就一直卡在加载态,什么提示都没有。
promisify把 success/fail 双回调转成 Promise,...args展开在后面是为了保住插件原本的参数顺序(多数插件是(success, fail, options))。错误对象统一成Error,业务层catch的时候就能用err.message直接判断类型。
3.2 获取地理位置:精度、超时与缓存三参数的取舍
定位这块插件复用了 W3C 标准接口,所以调法和浏览器里几乎一样,但参数默认值在真机上往往不合适:
// src/native/geolocation.js import { whenReady, promisify } from './index'; /** * 获取当前定位 * @param {Object} opts * @param {boolean} opts.highAccuracy 是否用 GPS 高精度,默认 false(走网络定位更快) * @param {number} opts.timeout 超时毫秒数,默认 10000 * @param {number} opts.maxAge 允许的缓存时长毫秒,默认 0 表示每次都取新值 */ export async function getPosition(opts = {}) { const { highAccuracy = false, timeout = 10000, maxAge = 0 } = opts; await whenReady(); const positionOptions = { enableHighAccuracy: highAccuracy, timeout, maximumAge: maxAge }; const pos = await promisify( navigator.geolocation.getCurrentPosition, [positionOptions] ); // 原始结果字段很全,业务层通常只关心前三个,这里做一次收敛 return { latitude: pos.coords.latitude, longitude: pos.coords.longitude, accuracy: pos.coords.accuracy, // 单位米,越小越可信 timestamp: pos.timestamp // 定位时刻,判断数据新鲜度用 }; }enableHighAccuracy: true会切到 GPS,代价是首次定位可能要 10 到 30 秒,室内基本定不上;false走基站和 WiFi,3 秒内出结果但精度可能到几百米。签到类场景我一般先用false快速出个位置把界面点亮,用户手动点「重新定位」时再用true拉高精度。maximumAge: 0表示不吃系统缓存,某些 Android 机型会缓存上一次的定位结果长达几分钟,不加这个参数会出现「人都换城市了,坐标还是老地方」的玄学问题。
pos.coords.accuracy这个字段务必带回业务层。我见过不下三次「定位不准」的反馈,最后一查全是accuracy三百多米还拿去算距离。做个判断,超过 100 米就提示用户到空旷处重试,比什么都强。
3.3 手机振动:两个平台行为不一样的接口
振动接口最短,坑却最隐蔽。Android 上navigator.vibrate接受数组表示节奏,iOS 只认固定时长,而且必须由用户交互触发:
// src/native/vibrate.js import { whenReady } from './index'; /** * 触发振动 * @param {number|number[]} pattern 毫秒数或 [振动, 停顿, 振动, ...] 节奏数组 * 注意:iOS 会忽略数组节奏,只取总时长做一次固定振动 */ export async function vibrate(pattern = 50) { await whenReady(); if (!navigator.vibrate) { // 桌面浏览器、部分 iOS 版本没有这个 API,降级成视觉反馈交给调用方处理 return { supported: false }; } const ok = navigator.vibrate(pattern); // 返回值表示系统是否接受本次请求,不代表一定产生了振动(静音模式会吞掉) return { supported: true, accepted: ok }; }返回值那行注释很重要。很多同学把navigator.vibrate的返回值当成「振了没」,结果在静音模式下被误导。它只表示系统接受了这个请求,实际有没有振受系统设置、勿扰模式、低电量模式影响,这个只能在业务上做兜底:振动通常用于操作成功提示,那就同时给一个 Toast 或按钮状态变化,别把全部反馈押在振动上。
节奏数组[100, 50, 100]在 Android 上是「振 100 停 50 再振 100」,做扫码成功的双短振很合适。iOS 侧不认这个模式,会走成一次性振动,所以文案和节奏设计上不能假设两端表现一致。
3.4 调取手机图片:sourceType 与返回格式的取舍
拍照和选图是同一个插件的两种模式,靠sourceType区分。这块最容易翻车的地方是返回格式:base64 会把图片撑成几 MB 的字符串塞进 JS 内存,低端机直接 OOM;走FILE_URI则拿到一个file://路径,展示方便但要上传还得转。
// src/native/camera.js import { whenReady, promisify } from './index'; /** * 调起相机或相册 * @param {Object} opts * @param {'CAMERA'|'PHOTOLIBRARY'} opts.source 来源,默认相机 * @param {number} opts.quality JPEG 质量 0-100,默认 50 * @param {number} opts.targetWidth 目标宽度,0 表示不缩放 * @param {'FILE_URI'|'DATA_URL'} opts.dest 返回形式,默认 FILE_URI */ export async function pickImage(opts = {}) { const { source = 'CAMERA', quality = 50, targetWidth = 1080, dest = 'FILE_URI' } = opts; await whenReady(); const options = { quality, destinationType: dest === 'DATA_URL' ? Camera.DestinationType.DATA_URL : Camera.DestinationType.FILE_URI, sourceType: source === 'PHOTOLIBRARY' ? Camera.PictureSourceType.PHOTOLIBRARY : Camera.PictureSourceType.CAMERA, targetWidth, // 按宽度等比缩放,能显著降低上传体积 targetHeight: targetWidth, // 与宽度相同即为正方形裁剪区域 correctOrientation: true, // 修正拍照方向,不加这行竖拍照片会躺倒 saveToPhotoAlbum: false, // 拍完不存系统相册,避免污染用户图库 allowEdit: false // 关掉系统裁剪页,各机型裁剪 UI 不一致 }; const result = await promisify(navigator.camera.getPicture, [options]); return { // DATA_URL 模式下插件返回的是纯 base64,需要自己拼前缀 uri: dest === 'DATA_URL' ? `data:image/jpeg;base64,${result}` : result, isBase64: dest === 'DATA_URL' }; }correctOrientation: true这一行是我见过被漏得最多的配置。不加它,Android 部分机型竖着拍的照片返回后是横的,因为 EXIF 里的方向信息没有被应用。这个问题在模拟器上永远复现不了,只有真机拍才有。
destinationType的选择要看后续用途。只是页面预览、发给后台做 OCR,FILE_URI更省内存;需要直接上传到只收 base64 的老接口,才用DATA_URL,并且一定要把targetWidth压到 800 到 1080 之间。1920 宽的 base64 图片字符串能到 5MB,WebView 里 hold 住两张就开始卡了。
Camera.DestinationType这个全局常量是插件注入的,必须在deviceready之后才存在,所以取值动作放在await whenReady()后面。这也是为什么不能把 options 对象提到模块顶层定义——顶层执行时Camera还不存在,直接ReferenceError。
3.5 扫描二维码:扫一扫的连续扫与单次扫
扫码插件挂在cordova.plugins.barcodeScanner上,和其他三个不一样,不是navigator.xxx。这个命名不统一是历史原因,记一下就行:
// src/native/scanner.js import { whenReady, promisify } from './index'; /** * 调起扫一扫 * @param {Object} opts * @param {boolean} opts.preferFrontCamera 是否优先前置摄像头,默认 false * @param {boolean} opts.showFlipCameraButton 是否显示前后摄切换按钮 * @param {string[]} opts.formats 限定识别的码制,减少误识别 */ export async function scanCode(opts = {}) { const { preferFrontCamera = false, showFlipCameraButton = true, formats = ['QR_CODE', 'CODE_128', 'EAN_13'] } = opts; await whenReady(); const result = await promisify(cordova.plugins.barcodeScanner.scan, [{ preferFrontCamera, showFlipCameraButton, showTorchButton: true, // 暗光环境必备,用户不会自己去开手电 prompt: '将二维码放入框内', // iOS 顶部提示文案 resultDisplayDuration: 0, // 识别后不弹结果浮层,直接由业务层接管 formats: formats.join(',') // 注意:这里要字符串,不是数组 }]); // cancelled 为 true 表示用户主动返回,不是失败 return { cancelled: result.cancelled, text: result.text || '', format: result.format || '' }; }formats必须传逗号分隔的字符串,传数组在部分插件版本里会被忽略,然后退化成识别全部码制,结果是扫个商品条码也能返回一串数字,业务层一脸问号。这个参数我一般只保留业务真正需要的两三种,识别率和速度都会好一些。
cancelled字段要和失败区分开。用户按返回键退出扫码,走的是 success 回调且cancelled: true,不是 fail 回调。如果业务层把cancelled当错误弹提示,用户会觉得这 App 很烦。resultDisplayDuration: 0也是同理,设为 0 后识别完立刻回传,界面切换由 Vue 自己控制,比插件自带的浮层可控得多。
showTorchButton: true看着是个小配置,实际影响很大。仓库、地下车库这类场景光线差,没有手电按钮用户只能干瞪眼,而这个按钮加一行配置就有。
4. 调试与构建:从 deviceready 到真机安装包
代码写完只是开始,这套组合真正的门槛在「怎么让它跑起来看见效果」。浏览器里能跑通不代表插件能用,反过来真机报错在浏览器里完全看不到,得有一套分层的调试策略。
4.1 环境准备与第一次构建
环境这块,Android 侧要 JDK、Android SDK、Gradle,iOS 侧必须有 macOS 加 Xcode。Cordova 的版本管理建议全局装一份,别混着用:
# 全局安装 cordova CLI,版本锁定在团队统一的号段 npm install -g cordova@12 # 检查环境是否齐全,缺什么它会直接告诉你 cordova requirements # 在壳工程里构建 Android debug 包 cd cordova-shell cordova build android # 产物路径,直接 adb install 或者拷到手机上装 # platforms/android/app/build/outputs/apk/debug/app-debug.apkcordova requirements这一步别跳过。它会逐项检查 JDK 版本、SDK 路径、Gradle、以及 iOS 的 Xcode 命令行工具,输出里哪项打叉就补哪项。我见过折腾两小时构建失败,最后发现是 JDK 21 而 Gradle 版本不支持——这种问题requirements一眼就看出来了。
构建前记得先把 Vue 打出来并同步到www:
# 在 vue-app 目录构建 cd vue-app && npm run build # 清空旧产物再拷,避免上一版残留文件混进去 rm -rf ../cordova-shell/www/* cp -r dist/* ../cordova-shell/www/ # 回到壳工程构建 cd ../cordova-shell && cordova build androidrm -rf那一步是有必要的。Vue 每次构建生成的文件名带 hash,不清空的话www/js/下面会堆一堆历史版本,包体越来越大还查不出原因。这个流程可以写进package.json的 scripts 里做成一条命令,减少手动失误。
4.2 浏览器调试与真机调试的分工
两套调试各有各的用处,不能互相替代。浏览器npm run serve主要看布局、路由、业务逻辑,插件相关的调用全部会走NOT_IN_CORDOVA分支,正好用来验证降级路径写没写对。真机则看插件、权限、性能。
真机调试的入口在两个地方。Android 用 Chrome 的chrome://inspect查看 WebView 的控制台,原生侧的日志在adb logcat里:
# 只看 Cordova 相关日志,过滤掉系统噪音 adb logcat | grep -iE "cordova|CameraLauncher|Geolocation" # 装包并启动 adb install -r platforms/android/app/build/outputs/apk/debug/app-debug.apk-r表示覆盖安装,保留应用数据,调试期省事。但要提醒一句:涉及权限的改动,覆盖安装有时候不生效,因为旧版本的权限授予状态还在,得先adb uninstall <包名>再装。权限相关的诡异问题,先卸载重装一遍,能省大量时间。
iOS 侧用 Safari 的「开发」菜单连真机 WebView,原生日志在 Xcode 的 Devices and Simulators 窗口里看。iOS 的权限弹窗只弹一次,拒绝了之后不会再弹,必须去系统设置里手动开,测试时要记着这一点,不然会以为是代码问题。
一个实用技巧:在index.html里加一段全局错误捕获,把错误直接画到页面上,真机没连调试器的时候也能看到:
// 真机上没有控制台时的可视化兜底 window.addEventListener('error', (e) => { const box = document.createElement('pre'); box.style.cssText = 'position:fixed;bottom:0;left:0;right:0;max-height:40%;overflow:auto;' + 'background:rgba(0,0,0,.85);color:#f66;font-size:12px;padding:8px;z-index:99999;margin:0'; box.textContent = `[error] ${e.message}\n${e.filename}:${e.lineno}`; document.body.appendChild(box); });这段代码上线前要删掉或者用环境变量关掉,但调试期它救过我很多次——尤其是把包发给测试同事、对方只说「打不开」的时候,让他截个图就能定位。
4.3 出包参数:签名、图标与版本号
正式包和 debug 包的差别主要在签名和资源。Android 用cordova build android --release -- --keystore=... --storePassword=... --alias=... --password=...,参数一长串,建议写进build.json让 Cordova 自己读:
{ "android": { "release": { "keystore": "./keys/release.keystore", "storePassword": "******", "alias": "app", "password": "******", "keystoreType": "" } } }密码明文放在仓库里不安全,实践中会把build.json加进.gitignore,用 CI 的环境变量注入,或者本地维护一份模板build.json.example。keystoreType留空让 Cordova 自动识别,写错了会报一个和实际原因完全无关的错。
版本号在config.xml的<widget version="1.0.0" android-versionCode="100">里改。version是展示给用户看的,android-versionCode是给商店判断升级的整数,每次发版必须递增,忘了加就会出现「上传成功但用户收不到更新」的情况。iOS 对应的是ios-CFBundleVersion。
图标和启动图用cordova-res从一张 1024 的源图批量生成,比手动往各尺寸目录里塞文件可靠得多:
# 源图放在 resources/icon.png 和 resources/splash.png cordova-res android --skip-config --copy cordova-res ios --skip-config --copy--skip-config表示不去改config.xml,只生成图片文件,避免它把你手写的配置覆盖掉。生成完检查一遍platforms/android/app/src/main/res/下的各密度目录,图标确实更新了再打包。
5. 避坑与排查:那些只在真机上炸的问题
下面这几条都是实际项目里真实发生过的,共同点是浏览器里一切正常,打包到真机才暴露。排查顺序建议从「有没有 cordova 对象」开始,逐层往外推。
5.1 构建期:装完插件仍然 undefined
现象:cordova plugin add明明成功了,cordova plugin list也能看到,但真机上navigator.camera还是 undefined,控制台没有任何报错。
原因:九成是www/index.html里没有引入cordova.js,或者引入的位置不对。Cordova 的脚本注入依赖这个<script>标签,缺了它桥根本不会建立,插件自然也不会挂载。另一种可能是引入路径写成了绝对路径/cordova.js,在file://下解析失败。
解决:确认index.html的<body>末尾有<script src="cordova.js"></script>,并且是相对路径。构建后www目录里确实没有这个文件,它是运行时由平台注入的,404 属正常。用一个最小的判断确认桥是否建立:
// 桥建立的最直接证据,比检查 navigator.camera 更早可判 console.log('platform:', window.cordova && window.cordova.platformId); console.log('version:', window.cordova && window.cordova.version);两个都打出来是 undefined,就不要再往下查插件了,问题一定在cordova.js的加载上。
5.2 运行期:deviceready 迟迟不触发
现象:页面白屏或者一直转圈,代码里的whenReady永远 pending,8 秒超时后抛DEVICEREADY_TIMEOUT。Android 上偶尔还能看到,iOS 上几乎没有。
原因:deviceready事件在 Cordova 加载完成后派发,如果监听注册得太晚(比如在异步路由懒加载之后才注册),事件已经派发完了,就永远等不到。另一种情况是某个插件初始化时抛了异常,把后续的派发流程打断。
解决:把whenReady()的调用提前到main.js里应用挂载之前,确保监听在任何业务代码之前注册。同时监听window.onerror,看看是不是某个插件在初始化阶段就炸了。用adb logcat过滤Cordova关键字,能看到插件注册的完整过程,哪个插件没打出来就是它的问题。
5.3 权限类:iOS 相机一调就闪退
现象:点拍照按钮,应用直接退出,没有任何提示。Android 正常,iOS 必现。
原因:Info.plist里缺NSCameraUsageDescription。iOS 在申请敏感权限时如果没有对应的描述键,系统会直接终止进程,这是硬性规定,不是崩溃日志能轻易看出来的那种。
解决:按 2.3 里的config.xml片段补齐三项描述。补完之后必须重新cordova prepare ios并重新构建,改config.xml不会自动同步到已有的 Xcode 工程。验证方式是打开platforms/ios/*/Info.plist搜一下键名在不在。另外提醒,iOS 权限弹窗只弹一次,测试时被拒了要去系统设置里手动打开,或者卸载重装。
5.4 内存类:选几张图就崩
现象:从相册连续选三五张图后应用卡死或闪退,Android 低端机高发。
原因:destinationType用了DATA_URL,每张图变成几 MB 的 base64 字符串常驻 JS 堆,WebView 的内存上限本来就低,几张就超了。另一个常见诱因是targetWidth没设,原图直接进来。
解决:默认改回FILE_URI,只在接口强制要求时才用 base64,并且必须配targetWidth: 1080以内。用FILE_URI时页面展示<img src="file:///...">在部分 Android 版本上会被安全策略拦,需要用cordova-plugin-file的resolveLocalFileSystemURL转成可用的 URL 再赋给src。选完图及时把上一次的结果置空,别在数组里无限累积。
5.5 扫码类:识别成功但拿不到结果
现象:相机画面正常,二维码也在框内,但扫完什么都不返回,或者返回一个和二维码内容无关的字符串。
原因:两种可能。一是formats传了数组而不是逗号字符串,插件忽略了限定条件并退化成全码制识别;二是resultDisplayDuration没设成 0,识别结果被插件的浮层拦截,业务层收到的是浮层关闭后的空结果。
解决:formats一定传'QR_CODE'这样的字符串,多个用逗号拼。resultDisplayDuration: 0关掉浮层。还有一个不易察觉的点:cordova.plugins.barcodeScanner在某些插件分支里挂载名不同,deviceready之后打个console.log(Object.keys(cordova.plugins))看看实际挂的是什么,比对着文档猜快得多。
6. 二次开发改造:把四类原生调用收拢成一层可替换的能力层
项目跑通之后,接着要做的是让它经得起改。我的做法是在src/native/外面再包一层能力接口,业务页面只认这个接口,不认具体插件。这样做的直接收益是:Web 端可以给一套 mock 实现,让同事在浏览器里就能走通完整业务流程;将来要换成别的桥接方案,也只改这一个目录。
// src/native/facade.js —— 业务层唯一入口 import { getPosition } from './geolocation'; import { vibrate } from './vibrate'; import { pickImage } from './camera'; import { scanCode } from './scanner'; // Web 环境下的降级实现,保证浏览器里点按钮不报错 const webFallback = { getPosition: async () => ({ latitude: 39.9, longitude: 116.4, accuracy: 50 }), vibrate: async () => ({ supported: false }), pickImage: async () => ({ uri: '', isBase64: false }), scanCode: async () => ({ cancelled: true, text: '', format: '' }) }; const isNative = typeof cordova !== 'undefined'; export const device = isNative ? { getPosition, vibrate, pickImage, scanCode } : webFallback;业务侧调用就变成device.scanCode(),try/catch里按err.message分支处理NOT_IN_CORDOVA、DEVICEREADY_TIMEOUT、插件原始错误三种情况。这里有个判断原则我一直在用:
| 场景 | 处理策略 | 理由 |
|---|---|---|
| 定位失败 | 降级手动输入地址 | 定位受环境影响大,不能堵死流程 |
| 振动失败 | 静默忽略 | 纯体验增强,失败不影响业务 |
| 拍照取消 | 不提示错误 | 用户主动取消不是异常 |
| 扫码失败 | 提示重试并给手输入口 | 码面污损、光线差都可能导致失败 |
验证改造是否到位,我有个笨办法但很有效:把cordova.js从index.html里临时注释掉,在浏览器里把四个功能各点一遍,如果页面不白屏、不报未捕获异常、每个功能都有明确的降级表现,说明这层封装是站得住的。反过来,如果哪里直接抛Cannot read property of undefined,那就是还有业务代码绕过 facade 直接调了navigator.xxx,得揪出来。
从那以后我每次接新的 Cordova 插件,第一件事都是在 facade 里先写降级实现,再写原生实现。这个顺序看起来多余,但它逼着我在动手之前就想清楚「这个能力挂了业务怎么办」,比事后补牢靠得多。希望帮到你。
本文还有配套的精品资源,点击获取