ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Vue+Cordova混合开发:四类原生能力接入与真机联调实践

Vue+Cordova混合开发:四类原生能力接入与真机联调实践

简介:面向具备Vue基础、希望把Web界面扩展到移动端的前端开发者,这份集成示例围绕Cordova原生能力展开,重点解决地理位置获取、手机振动、相机与相册调取、二维码扫描等场景中的插件接入与Vue组件调用问题。内容涉及camera、geolocation、vibration、barcode-scanner等插件的安装配置思路,并延伸到权限请求、不同平台兼容处理、真机运行与livereload调试等实践环节,可用于搭建跨平台应用原型、对照排查原生调用异常。包体约14.48MB,文件总数显示为0、类型明细暂无数据,实际文件组成以压缩包为准。已有534人学习或下载,适合作为Vue与Cordova混合开发的参考样本,帮助读者理解Web层与原生层之间的调用边界、插件参数调整和常见兼容问题的处理方向。

1. 签到页上那三个「点不动」的按钮:Vue 项目缺的正是这一层原生壳

先摆一个真实场景。某个活动现场签到页,Vue 写的界面在桌面浏览器里跑得干干净净,一上真机就全露馅:拍照打卡要用户先退出应用打开系统相机、扫一扫得截图再识别、定位精度停在「500 米以内」,点一下「签到成功」手机连震都不震一下。Vue 擅长的是快速迭代业务界面,缺的从来不是页面,而是跟操作系统打交道的那几个接口。Cordova 就是补这层的东西:Vue 打包出来的静态文件塞进 WebView,插件把相机、定位、振动、扫码这些系统能力挂到navigator上。这套组合适合手头已经有一个 Vue 移动端项目、要装成 App 上架、又不想把界面重写一遍的团队。拍照、扫一扫、定位这三件事,每个都有自己的一套参数和坑,下面按真实调用顺序拆。

2. 工程骨架与桥接原理:Vue 打包产物怎么被 Cordova 接管

2.1 目录结构:Vue 管界面,Cordova 只当外壳

我一般把两者放同一个仓库根目录,不拆成两个工程。原因很实际:拆开之后每次改业务代码都要手动把dist拷到另一个仓库,版本一多就没人记得拷的是哪一版。同仓库的好处是构建目录直接指向 Cordova 的www,npm run build之后产物就落在壳里,cordova prepare只负责注入平台相关的文件。

demo-app/ ├── src/ # Vue 业务代码 │ ├── native/ # 原生能力适配层,全篇重点 │ │ ├── bridge.js # deviceready 等待与插件探测 │ │ ├── geo.js # 定位 │ │ ├── vibrate.js # 振动 │ │ ├── camera.js # 拍照 / 相册 │ │ └── scanner.js # 扫一扫 │ ├── views/ │ │ └── Checkin.vue │ ├── router/index.js │ └── main.js ├── www/ # Cordova 加载的站点目录(构建产物,不进 git) ├── platforms/ # 各平台原生工程,由 cordova 生成 ├── plugins/ # 插件源码,由 cordova 生成 ├── config.xml # 应用名、权限、平台配置 ├── index.html # Vue 的入口模板 ├── vite.config.js └── package.json

www/、platforms/、plugins/三个目录都加进.gitignore。它们能由cordova prepare和cordova plugin add重新生成,提交上去只会让每次合并都产生大量噪声,还会在换机器时因为路径不一致冲突。

src/native/这个目录是我单独加的,Cordova 官方模板里没有。它存在的意义是把「插件 API 长什么样」和「页面怎么用」隔开。页面里永远只import { native } from '@/native',不直接摸navigator.camera。后面要换成别的容器或者补 H5 降级,只改这一层。

2.2 deviceready 的时序:为什么 setup 里直接调插件一定失败

cordova.js是构建时被注入到www/index.html的,页面加载后它要去和原生层握手,握手成功才在document上派发一次deviceready。在这之前,window.cordova可能是undefined,navigator.camera也可能还不存在。而 Vue 的main.js执行时间点完全不受这个事件控制——大概率早于它。

这就是很多人第一次接插件时的翻车现场:onMounted里写navigator.camera.getPicture(...),报Cannot read property 'getPicture' of undefined。加个setTimeout(2000)有时候能跑,换台慢一点的机器又挂,玄学得很。

我的做法是把等待封装成一个可复用的 Promise,同时处理三件事:原生环境判断、事件只监听一次、超时兜底。

// src/native/bridge.js const READY_TIMEOUT = 5000 let readyPromise = null // 判断当前是不是跑在 Cordova 壳里,浏览器里打开时直接返回 false export function isNative() { return typeof window !== 'undefined' && !!window.cordova } export function waitReady() { if (!isNative()) return Promise.resolve(false) if (readyPromise) return readyPromise // 多次调用共享同一个 Promise readyPromise = new Promise((resolve) => { if (window.__cordovaReady) return resolve(true) const onReady = () => { window.__cordovaReady = true resolve(true) } document.addEventListener('deviceready', onReady, false) // 兜底:部分机型从后台恢复后不再派发该事件,超时后按未就绪处理 setTimeout(() => resolve(window.__cordovaReady === true), READY_TIMEOUT) }) return readyPromise }

readyPromise缓存这一步容易被忽略。如果每个能力模块各写一份监听,页面上同时调用定位和相机就会挂两个deviceready监听器,虽然事件只触发一次不会重复执行,但 Promise 实例多了之后排查问题会很难看清到底哪个先 resolve。共享一个实例,日志里一眼就能定位。

window.__cordovaReady这个标记是为了处理「事件已经错过」的情况。比如用户在扫码页停留很久,Vue 组件重新挂载后再调用插件,此时deviceready早就派发完了,再监听就是等一个永远不会来的事件。有标记在,直接 resolve。

超时兜底给 5 秒,不是随便定的。真机冷启动时 WebView 初始化加插件加载,实测大多在 1 到 2 秒内完成,给到 5 秒足够宽松,又不至于让用户对着转圈等太久。

2.3 能力适配层:让页面不关心自己跑在哪

桥接层解决的是「什么时候能调」,适配层解决的是「怎么调才不脏」。四个能力各有各的回调风格:定位是getCurrentPosition(success, error, options),相机是getPicture(success, error, options),扫码是prepare完再scan,振动最简单,直接调。如果这些风格原样漏到页面里,一个签到页会被回调函数淹没。

统一成 Promise,并且把错误码翻译成业务能判断的字符串:

// src/native/index.js import { isNative } from './bridge' import { getCurrentPosition } from './geo' import { vibrate } from './vibrate' import { takePhoto } from './camera' import { startScan } from './scanner' export const native = { isNative, getCurrentPosition, vibrate, takePhoto, startScan }

页面里这样用,try/catch抓住的是可读的错误:

// src/views/Checkin.vue import { native } from '@/native' async function onCheckin() { try { const pos = await native.getCurrentPosition({ timeout: 15000 }) await submit({ lat: pos.lat, lng: pos.lng }) await native.vibrate('success') } catch (e) { // e.message 形如 PERMISSION_DENIED / TIMEOUT,可直接映射成提示文案 showToast(ERROR_TEXT[e.message] || '签到失败,请重试') } }

这里没有把四个插件包装成类或者 Vue 插件,是因为它们之间没有共享状态,用普通模块函数最省事。真要加状态(比如扫码是否正在显示),放模块内部的变量即可,不需要引入额外抽象。

浏览器里跑npm run dev时,isNative()返回 false,getCurrentPosition会退回浏览器实现,takePhoto会退回<input type="file" accept="image/*">。这样 UI 联调不用每次都插真机,改样式、调布局的效率高很多。

2.4 构建配置:base、输出目录和 cordova.js 注入

Vue 项目默认打出来的index.html引用资源用的是绝对路径/assets/index-xxxx.js。这个路径在 WebView 里加载file://页面时直接 404,表现就是白屏。所以base必须改成相对路径./。

同时cordova.js只在真机环境存在,开发环境里引用它会报 404,控制台一片红。我的处理是只对构建产物注入这个 script。

// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' // 仅在 build 时把 cordova.js 注入到 index.html 的 head 里 function injectCordovaScript() { return { name: 'inject-cordova-script', apply: 'build', transformIndexHtml(html) { return html.replace('</head>', ' <script src="cordova.js"></script>\n</head>') } } } export default defineConfig({ base: './', plugins: [vue(), injectCordovaScript()], build: { outDir: 'www', emptyOutDir: true, assetsDir: 'assets' }, server: { host: true } })

emptyOutDir: true会清空www,把上次构建残留的旧 chunk 一起删掉,避免「改了代码真机还是旧效果」这种容易怀疑人生的现象。cordova.js本身不在www里常驻,它由cordova prepare在构建阶段复制进去,所以清空不影响。

路由同样要改。createWebHistory依赖服务端把任意路径重写到index.html,而 WebView 加载的是本地文件,/checkin/result这种地址没有对应文件。线上环境用createWebHashHistory,路径变成#/checkin/result,刷新和回退都正常。

// src/router/index.js import { createRouter, createWebHashHistory } from 'vue-router' export default createRouter({ history: createWebHashHistory(), // file:// 下必须用 hash 模式 routes: [ { path: '/', component: () => import('@/views/Home.vue') }, { path: '/checkin', component: () => import('@/views/Checkin.vue') } ] })

3. 四个原生能力逐个落地:定位、振动、相机、扫一扫

3.1 地理定位:精度、超时和缓存三个参数决定体验

cordova-plugin-geolocation装完之后会覆盖navigator.geolocation,接口和浏览器标准一致,所以代码本身不复杂,难的是参数怎么配。签到场景要求「人确实在这里」,参数就得往高精度、少缓存偏。

// src/native/geo.js import { waitReady } from './bridge' const GEO_ERR = { 1: 'PERMISSION_DENIED', 2: 'POSITION_UNAVAILABLE', 3: 'TIMEOUT' } export function getCurrentPosition(options = {}) { const { enableHighAccuracy = true, timeout = 12000, maximumAge = 0 } = options return waitReady().then((ready) => { // 非原生环境下退回浏览器实现,方便桌面调试 if (!ready && !navigator.geolocation) { return Promise.reject(new Error('GEO_UNSUPPORTED')) } return new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition( (pos) => resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude, accuracy: pos.coords.accuracy, // 精度半径,单位米,务必存下来 timestamp: pos.timestamp }), (err) => reject(new Error(GEO_ERR[err.code] || 'GEO_UNKNOWN')), { enableHighAccuracy, timeout, maximumAge } ) }) }) }
参数作用签到场景取值说明
enableHighAccuracy优先走 GPS 而非基站/WiFi 定位true室内 GPS 搜不到星会一直等到超时,需配合较长 timeout
timeout最长等待毫秒数12000低于 10 秒在冷启动下大概率直接报 TIMEOUT
maximumAge允许复用多久以内的缓存位置0填 0 每次强制重新定位,代价是慢

accuracy这个字段一定要落到业务数据里,不能只存经纬度。真机上经常出现 GPS 还没锁星就返回一个基于基站的坐标,误差半径几百米。业务上可以用accuracy > 100判断为「疑似漂移」,提示用户到开阔处重试,比事后再去扯皮有用得多。

如果需要连续轨迹(比如巡检),用watchPosition包一层节流,别让回调按原始频率往 Vue 响应式数据里写,否则页面会被高频更新拖卡。

3.2 振动:把反馈做成可配置的节奏,而不是随手一下

振动看起来最简单,实际上是四个能力里跨平台差异最大的。cordova-plugin-vibration会扩展navigator.vibrate,安卓上能按数组播放「响-停-响」的节奏,iOS 上时长参数基本无效,只能决定「震不震」。所以设计上不能让页面直接传毫秒数,要传语义化的类型。

// src/native/vibrate.js import { waitReady } from './bridge' const PATTERNS = { tap: 15, success: [0, 30, 60, 30], error: [0, 80, 50, 80] } export async function vibrate(type = 'tap') { await waitReady() // 部分老安卓 WebView 没有这个 API,静默降级,不能因为振动失败中断业务流程 if (typeof navigator.vibrate !== 'function') return false const pattern = PATTERNS[type] ?? type try { return navigator.vibrate(pattern) } catch (e) { return false } }
类型安卓表现iOS 表现使用场景
tap15ms 短震一次系统级轻反馈按钮点击
success两段短震同上,节奏无效提交成功
error两段长震同上校验失败

关键点是try/catch和静默返回。振动是锦上添花的反馈,不能因为一台设备不支持就让整个签到流程抛异常。这点血泪经验来自一次线上事故:某个定制 ROM 上navigator.vibrate存在但调用直接抛错,把后面的提交成功回调也带崩了。

3.3 相机与相册:FILE_URI 和 DATA_URL 的取舍

cordova-plugin-camera的getPicture是全篇参数最多的一个接口。最需要提前定下来的是destinationType:DATA_URL直接返回 base64,页面拿来就能显示,但一张 4000px 的原图转 base64 能到十几兆,低端机直接内存告急甚至闪退;FILE_URI返回文件路径,轻量,但要处理路径渲染和后续上传。

我一般默认FILE_URI,同时用targetWidth/targetHeight限制尺寸。

// src/native/camera.js import { waitReady } from './bridge' export async function takePhoto(opts = {}) { const ready = await waitReady() if (!ready || !navigator.camera) throw new Error('CAMERA_PLUGIN_MISSING') const { source = 'camera', // camera | album quality = 70, // 0-100,压缩质量 maxSize = 1280, // 长边上限,同时限制宽高 saveToAlbum = false // 是否同时存进系统相册 } = opts const Camera = navigator.camera return new Promise((resolve, reject) => { Camera.getPicture( (imageUrl) => resolve({ url: imageUrl, source }), (message) => reject(new Error(`CAMERA_FAIL:${message}`)), { quality, destinationType: Camera.DestinationType.FILE_URI, sourceType: source === 'album' ? Camera.PictureSourceType.PHOTOLIBRARY : Camera.PictureSourceType.CAMERA, encodingType: Camera.EncodingType.JPEG, mediaType: Camera.MediaType.PICTURE, allowEdit: false, // 系统裁剪框在不同 ROM 上差异极大,一律关掉 correctOrientation: true, // 不加这个,竖拍照片在页面里会横过来 saveToPhotoAlbum: saveToAlbum, targetWidth: maxSize, targetHeight: maxSize } ) }) }
参数建议值原因
destinationTypeFILE_URI避免大图 base64 撑爆内存
quality70再高肉眼无差别,体积翻倍
targetWidth/Height1280签到、工单类场景足够,上传快
allowEditfalse各厂商裁剪界面不一致,容易出兼容问题
correctOrientationtrue解决竖拍变横图
saveToPhotoAlbumfalse默认不污染用户相册,需要时再开

allowEdit这一项值得单独说。开了之后会调起系统裁剪界面,在某些定制系统上返回的路径格式和预期不一致,甚至裁剪完直接回调失败。业务上真想裁,用前端 canvas 自己裁更可控。

FILE_URI拿到的地址类似file:///storage/emulated/0/...,安卓 WebView 里直接塞进<img :src>一般能显示,但要确认没有配置过严的 CSP 拦截file:协议。iOS 上路径形态不同,不要自己做字符串拼接或截取,原样透传最安全。

3.4 扫一扫:原生相机层会盖住整个 WebView

扫码用cordova-plugin-qrscanner,它的行为和其他三个插件完全不同:启动后不是弹一个对话框,而是把原生相机预览铺满整个屏幕,盖在 WebView 上面。Vue 页面里写的遮罩、返回按钮、z-index全部无效,因为那已经是两层视图了。

这带来两个必须处理的点:一是扫描结束必须主动收起原生层,否则 WebView 永远被盖着;二是安卓物理返回键在扫码期间默认会退出应用,要接管。

// src/native/scanner.js import { waitReady } from './bridge' let prepared = false export async function startScan() { const ready = await waitReady() if (!ready || !window.QRScanner) throw new Error('SCANNER_PLUGIN_MISSING') // prepare 只需成功一次,重复调用会返回错误 if (!prepared) { await new Promise((resolve, reject) => { window.QRScanner.prepare((err, status) => { if (err) return reject(new Error(`PREPARE_FAIL:${err.name || 'UNKNOWN'}`)) if (!status.authorized) return reject(new Error('PERMISSION_DENIED')) prepared = true resolve() }) }) } await new Promise((resolve) => window.QRScanner.show(resolve)) return new Promise((resolve, reject) => { window.QRScanner.scan((err, text) => { // 无论成功失败,先收起原生层,再销毁释放相机 window.QRScanner.hide(() => { window.QRScanner.destroy(() => {}) prepared = false if (err) return reject(new Error(`SCAN_FAIL:${err.name || 'CANCELED'}`)) resolve(text) }) }) }) }

destroy之后prepared要重置为 false,因为相机资源已经释放,下次进入必须重新prepare。这一点如果漏了,第二次扫码会卡在show没有任何反应。

扫码期间接管返回键,放在页面组件的生命周期里:

// src/views/Checkin.vue(节选) import { onMounted, onUnmounted } from 'vue' function onBackKey(e) { e.preventDefault() // 阻止默认的退出应用行为 window.QRScanner.cancelScan(() => { window.QRScanner.hide(() => window.QRScanner.destroy(() => {})) }) } onMounted(() => document.addEventListener('backbutton', onBackKey, false)) onUnmounted(() => document.removeEventListener('backbutton', onBackKey, false))

这个插件在较新的系统版本上兼容性需要自己验证,尤其是权限弹窗和相机预览的适配。备选方案是换一个基于系统扫码能力的插件,接口形态差不多,适配层里换个实现即可,页面代码不用动——这正是 2.3 那层封装的价值。

4. 权限声明与真机联调:一次跑通的完整流程

4.1 config.xml 里把权限和用途说明一次写全

安卓 6.0 之后是运行时权限,但清单里不声明的话,运行时申请会直接失败。iOS 则是缺了用途说明字符串,应用启动即崩。这两件事都在config.xml里解决。

<?xml version='1.0' encoding='utf-8'?> <widget id="com.example.demo" version="1.0.0" xmlns="http://www.w3.org/ns/widgets"> <name>签到助手</name> <content src="index.html" /> <!-- iOS 用途说明,缺失会直接闪退 --> <preference name="CAMERA_USAGE_DESCRIPTION" value="需要使用相机拍摄现场照片" /> <preference name="PHOTOLIBRARY_USAGE_DESCRIPTION" value="需要读取相册中的图片用于上传" /> <preference name="LOCATION_WHEN_IN_USE_USAGE_DESCRIPTION" value="用于记录签到位置" /> <platform name="android"> <config-file target="AndroidManifest.xml" parent="/*"> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.VIBRATE" /> <!-- 新版本系统用这个,旧的读写外部存储权限已不再生效 --> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> </config-file> </platform> <platform name="ios"> <config-file target="*-Info.plist" parent="NSCameraUsageDescription"> <string>需要使用相机拍摄现场照片</string> </config-file> </platform> </widget>

com.example.demo这类应用标识在上架前必须换掉,而且一旦发布就不能再改,否则会被当成另一个应用。config-file的parent="/*"表示把节点插到清单根节点下,安卓权限必须放这个位置,放错层级打包时会报合并失败。

4.2 从装插件到真机跑起来

命令顺序有讲究。先装前端依赖,再添加平台,最后装插件——插件安装过程会修改平台工程里的原生配置,平台不存在时只能装进plugins/目录,等下次prepare才落位,容易产生「明明装了却调不到」的错觉。

# 1. 前端依赖 npm install # 2. 添加安卓平台(首次需要,之后可重复执行) npx cordova platform add android # 3. 安装四个原生插件 npx cordova plugin add cordova-plugin-geolocation npx cordova plugin add cordova-plugin-vibration npx cordova plugin add cordova-plugin-camera npx cordova plugin add cordova-plugin-qrscanner # 4. 构建前端产物并同步进原生工程 npm run build npx cordova prepare android # 5. 真机运行 npx cordova run android --device

cordova prepare这一步不能省。它负责把www拷进平台工程、注入各平台的cordova.js、读取config.xml生成权限声明。改了config.xml之后如果只run不prepare,经常会看到改了没生效。

需要连多台设备调试时加--target指定设备序列号。想看完整日志加--verbose,插件初始化失败的原因基本都能在这里看到。

4.3 调试:H5 那一套要换一半

真机上没有开发者工具面板,但 WebView 支持远程调试,在桌面浏览器里打开chrome://inspect这类调试页,能找到设备上正在运行的 WebView 并打开控制台。前端报错、console.log、网络请求都在这里看。

原生层的日志要走另一条路:

# 只看应用相关日志,过滤掉系统噪声 npx cordova run android --device --verbose # 或者在已有平台上直接抓 adb logcat | grep -i "cordova\|crash\|permission"

调试时最容易漏的是deviceready之前的状态。我的习惯是在main.js里先打一行console.log('native?', isNative()),再在waitReady的 resolve 处打console.log('ready', ready)。真机连上去一看这两行,就知道问题出在「壳没加载」还是「时序没等对」,能省下大量瞎猜时间。

5. 常见问题排查:五个把真机调崩的瞬间

5.1 报错navigator.camera is undefined

现象:页面点拍照按钮毫无反应,控制台报Cannot read property 'getPicture' of undefined,但插件确实装了。

原因:调用时机早于deviceready。另一种情况是在桌面浏览器里跑,window.cordova压根不存在。

解决:所有原生能力入口统一走waitReady(),并判断返回值。浏览器环境返回 false 时走降级分支,不要直接抛错。检查www/index.html里是否真的注入了cordova.js,构建配置里的注入插件写错apply是常见疏漏。

5.2 构建产物在真机上白屏

现象:cordova run之后应用启动,屏幕全白,控制台看不到明显报错,或者只报资源 404。

原因:base没改成./,index.html里引用的是/assets/xxx.js绝对路径;或者路由用了 history 模式,默认路径没有对应文件。

解决:vite.config.js里设base: './'和build.outDir: 'www',路由换createWebHashHistory。改完务必清一次www(emptyOutDir: true已经代劳),再用cordova prepare重新同步。

5.3 竖着拍的照片在页面里横过来

现象:调用相机拍完,<img>里显示的照片旋转了 90 度,只有部分机型出现。

原因:照片的 EXIF 里记录了方向,但 WebView 渲染时不读 EXIF,只按像素方向显示。相机插件默认不纠正方向。

解决:correctOrientation: true打开。如果还不行,说明该 ROM 的照片方向信息写在了别处,前端用 canvas 按 EXIF 二次旋转兜底。

5.4 扫码完成后页面被一层黑色盖住点不动

现象:扫描成功,回调也拿到了结果,但 Vue 页面看不见或者按钮全失效。

原因:QRScanner的相机预览是原生视图,覆盖在 WebView 之上。回调里只处理了业务,没有hide和destroy。

解决:在扫描回调里先把hide和destroy链式执行完,再做路由跳转或业务提交。安卓返回键也要接管,否则扫码时按返回直接退出应用,用户下次进来会以为崩了。

5.5 定位一直报 TIMEOUT

现象:getCurrentPosition每次都走失败回调,错误码 3。

原因:室内收不到 GPS 信号;用户只给了「大致位置」权限;或者timeout设得太短,冷启动还没锁星就超时了。

解决:timeout提到 12 到 15 秒,maximumAge设 0 保证每次重新定位。捕获到TIMEOUT时给用户明确指引(走到窗边或室外),而不是笼统的「定位失败」。定位权限被拒后不要反复弹,引导到系统设置页更实际。

6. 二次开发:把能力层做成可替换的接口,而不是散落各处的调用

四个能力都跑通之后,接下来真正决定这个项目好不好维护的,是能力层怎么组织。我见过的反例是所有页面各写各的:签到页自己写一遍相机参数,工单页复制粘贴一份;后来要统一把照片从 1280 压到 960,得全局搜索getPicture逐个改,十有八九漏掉一处。

我的做法是把src/native/定成一组带契约的模块:每个能力对外只暴露业务语义的函数,参数收进一个对象,错误统一成字符串码。这样改造时只需要盯住三个位置。

改造点一,参数配置集中化。把quality、maxSize、timeout这些散落的数值提到一个config.js,按环境区分:

// src/native/config.js export const NATIVE_CONFIG = { geo: { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }, camera: { quality: 70, maxSize: 1280, saveToAlbum: false }, scanner: { preferFrontCamera: false } }

改造点二,把插件依赖收敛到一个入口。现在调用的是navigator.camera、window.QRScanner,将来如果换成其他容器或者改用别的插件,只要新写一个同名模块,index.js里换个 import 就完成迁移。页面代码完全不动,这是这层封装最实在的收益。

改造点三,补一套 H5 mock,让 CI 能跑。原生插件在单元测试环境里全是 undefined,所以在测试里把native模块整体替换掉:

// tests/checkin.spec.js(vitest 示例) import { vi, test, expect } from 'vitest' vi.mock('@/native', () => ({ native: { isNative: () => false, getCurrentPosition: vi.fn().mockResolvedValue({ lat: 31.2, lng: 121.4, accuracy: 35 }), vibrate: vi.fn().mockResolvedValue(true), takePhoto: vi.fn().mockResolvedValue({ url: 'file:///mock.jpg' }), startScan: vi.fn().mockResolvedValue('https://example.com/checkin/1') } })) test('签到成功后展示结果页', async () => { const pos = await (await import('@/native')).native.getCurrentPosition() expect(pos.accuracy).toBeLessThan(100) })

验证方式也要分层。改完能力层先跑npm run dev,在桌面浏览器里过一遍降级路径,确认业务流程没断;再npm run build+cordova run android --device,在真机上按「定位 → 拍照 → 扫码 → 振动」的顺序走一遍,重点看权限首次弹窗、拒绝后重试、以及扫码返回键这三个分支。只测顺利路径的改造,上线后一定会在权限拒绝这条路上翻车。

还有一个具体技巧:给每次原生调用打一条带耗时和结果的日志,console.log('[native]', name, Date.now() - start, result)。真机上定位慢、相机启动慢这类问题,看日志比猜快十倍,而且用户反馈「点了没反应」的时候,你能立刻判断是没触发、触发了在等,还是回调丢了。

从那以后我每次接新的原生插件,都强制先写waitReady再写业务,参数全部进配置文件,绝不在页面里直接摸navigator。吃过一次亏就够,插件装了三遍却一直报 undefined 的那个下午,我到现在还记得。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表