Vue Cordova原生插件集成:拍照扫码定位与权限避坑

发布时间:2026/11/2 2:07:03
Vue Cordova原生插件集成:拍照扫码定位与权限避坑

简介:围绕Vue与Cordova原生能力整合的这份zip压缩包,面向已具备Vue基础、希望借助Web技术开发移动端应用的前端开发者与跨平台学习者,重点解决在Vue项目中接入定位、振动、相机/相册和二维码扫描等原生功能时的集成思路与配置问题。压缩包大小约14.48MB,文件总数暂显示为0,类型明细尚未提供,具体内容以实际解压结果为准;已有534人浏览学习。内容价值在于把Cordova环境准备、Vue单文件组件与Cordova入口页衔接、camera、geolocation、vibration、barcode-scanner等插件的调用方式,以及不同平台的权限申请、参数调整、真机部署与实时刷新调试等注意事项串联起来,形成一条学习线索;便于读者理解Vue负责界面与状态、Cordova负责原生桥接的协作方式,并据此排查插件安装、权限配置和跨平台兼容中的常见问题。

1. 从 WebView 到系统 API:vue Cordova 拍照、扫一扫、定位为什么必须走插件桥

在移动端 H5 里做拍照、扫一扫、定位,浏览器给出的能力总差一口气:navigator.vibrate在部分机型上直接是undefined,input file拍照拿不到原始方向,扫码得靠getUserMedia自己解码,定位精度和超时全看厂商心情。用 vue Cordova 把 Vue 页面装进原生壳,这四件事就落到了系统 API 上:定位走 GPS 与基站混合,拍照调系统相机,扫一扫交给原生解码器,振动直接打到马达。它适合手里已有 Vue 项目、要上架应用商店、又不想重写一套原生 UI 的团队,也适合只想用相机和定位、不愿背整套原生技术栈的单人开发者。代价是插件生态偏老,坑集中在权限、时序和返回值格式三处。

2. 构建链路与插件选型:把 Cordova 原生插件接进 Vue 工程的最小闭环

Cordova 的本质是一层 JS 到原生的桥:WebView 里的 JS 调cordova.exec(win, fail, '服务名', '方法名', 参数数组),桥把调用序列化后交给原生侧的 Plugin 类,执行完再按callbackId把结果回抛给 WebView。这套机制决定了三件事:插件必须在deviceready之后才可靠;所有回调都是"成功/失败"两个函数,不是 Promise;参数要能被 JSON 序列化,传不了函数和 DOM。Vue 这一侧要做的,就是把异步回调包装成 Promise、把能力探测收口、把构建产物正确送进壳工程的www目录。顺序错了,后面所有调试都是白费。

2.1 构建链路:Vue 产物怎么落到 www 里,base 为什么必须是 './'

最常见的翻车是页面能起、样式全丢、路由一跳就白屏。原因是 Cordova 用file://协议加载www/index.html,绝对路径/assets/index.js在file://下会被解析成文件系统根目录,直接 404。所以构建配置里base必须是相对路径,同时路由要用 hash 模式。

// vite.config.js —— Vue 侧构建配置 import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ base: './', // 必须相对;file:// 下绝对路径必然 404 build: { outDir: '../cordova-app/www', // 直接产出到壳工程的 www,省一次手工拷贝 emptyOutDir: true, // 每次清空,避免旧 hash 文件残留导致"改了没生效" target: 'es2015', // 老 WebView(Android 7/8)不认可选链和高阶语法 assetsInlineLimit: 4096 // 小图内联,减少一次 file:// 请求 }, plugins: [vue()] })

outDir指向壳工程,是为了让"改代码 →cordova run"这条链路少一步人工操作;emptyOutDir一定要开,Vite 产出带 hash 的文件名,不清空的话www里越堆越多,排查"为什么线上还是旧逻辑"时非常费时间。target设成es2015不是保守,是实测:部分定制 ROM 的 WebView 内核停在 Chromium 60 上下,可选链语法会直接抛Unexpected token,表现为整页白屏、控制台只有一行语法错误。

用 Vue CLI 的项目对应改vue.config.js:publicPath: './'、outputDir: '../cordova-app/www'、productionSourceMap: false。路由无论是 vue-router 3 还是 4,都切mode: 'hash',history模式在file://下没有服务端兜底,刷新即 404。壳工程这边,cordova create之后不需要动www里的内容,只保留cordova.js的引用位置 —— 注意它必须用<script src="cordova.js">标签引入,不能import,也不能被 Vite 打进 bundle,因为运行时这个文件是壳在启动时动态注入的。

2.2 插件清单与版本锁定:四个能力对应哪几个插件

四个能力对应四个插件,其中相机和扫码是重灾区,版本一定要钉死。Cordova 插件的语义化版本经常在小版本里改回调结构,latest在真机上炸的概率远高于收益。

能力插件前端调用入口Android 权限iOS 描述键
定位cordova-plugin-geolocationnavigator.geolocationACCESS_FINE_LOCATION、ACCESS_COARSE_LOCATIONNSLocationWhenInUseUsageDescription
振动cordova-plugin-vibrationnavigator.vibrateVIBRATE无
拍照/相册cordova-plugin-cameranavigator.camera.getPictureCAMERA(系统相机代拍可不申请)NSCameraUsageDescription、NSPhotoLibraryUsageDescription
扫一扫phonegap-plugin-barcodescannercordova.plugins.barcodeScanner.scanCAMERA、FLASHLIGHTNSCameraUsageDescription

装插件不要一口气全装完再排查,装一个跑一个,出问题能立刻定位到是哪个插件引入的。命令在壳工程根目录执行。

# 逐个安装并写入 config.xml,版本号显式指定 cordova plugin add cordova-plugin-geolocation@4.1.0 --save cordova plugin add cordova-plugin-vibration@3.1.2 --save cordova plugin add cordova-plugin-camera@6.0.0 --save cordova plugin add phonegap-plugin-barcodescanner@8.0.1 --save # 装完立刻核对,版本对不上先卸载再重装,别硬着头皮往下走 cordova plugin ls cordova clean # 清掉 platforms 里的中间产物,插件注册信息在下次 build 时重建

--save会把插件和版本写进config.xml,这是团队协作的底线;没有它,别人cordova prepare之后插件列表就是空的。cordova clean这一步经常被跳过,结果是"插件明明装了但cordova.plugins.barcodeScanner是 undefined"——插件在原生的注册发生在 build 阶段,platforms/里的旧产物不清,新插件不会进config.xml对应的原生注册表。

2.3 统一出口:把回调式插件包成 Promise

组件里直接写navigator.camera.getPicture(success, fail, opts),会出现三个问题:业务代码被三层嵌套回调撕碎、deviceready的判断散落各处、纯浏览器调试时到处undefined。收口到一个 bridge 模块,只暴露 Promise,业务侧只关心成功和失败。

// src/native/bridge.js —— 所有原生调用的唯一出口 let readyPromise = null export function waitForDevice() { if (readyPromise) return readyPromise // 缓存,多次调用只挂一次监听 readyPromise = new Promise((resolve) => { const inCordova = !!(window.cordova && window.cordova.platformId !== 'browser') if (!inCordova) { resolve(false) // 纯浏览器:走降级分支,不让页面直接报错 return } document.addEventListener('deviceready', () => resolve(true), { once: true }) }) return readyPromise } export function callNative(nativeFn, fallbackFn) { return waitForDevice().then((isNative) => { if (!isNative) return fallbackFn() return nativeFn() }) }

{ once: true }不能省。热更新或页面重新挂载时,重复注册的监听会让同一个调用触发多次,表现出来是"拍一次照回调了三次"。platformId === 'browser'这个判断是给cordova serve和纯浏览器联调用的,这时候navigator.camera不存在,直接走fallbackFn比抛异常友好。fallbackFn也不该是空函数,一般在这里挂 H5 的input file或提示文案,让 Web 端至少能走通主流程,不至于前端同学拿不到后端联调环境就完全没法开发。

3. 四个原生能力的落地实现:定位、振动、拍照、扫一扫的参数怎么设

四个能力的参数各有各的脾气。定位的timeout和maximumAge决定用户体验是"秒回"还是"转圈十秒",相机的destinationType决定后面上传会不会内存爆掉,扫码的formats白名单直接影响识别速度,振动的 pattern 数组在 iOS 上会被截断成"响一下"。这一节按调用代价从低到高排:振动、定位、相机、扫码,每个都给可直接抄的封装和参数解释。

3.1 定位:getCurrentPosition 的三个参数决定成败

Cordova 的定位插件把navigator.geolocation补到了原生层,所以 API 形态和浏览器一致,但参数在真机上的表现差别很大。

// src/native/geo.js import { callNative } from './bridge' const GEO_OPTS = { enableHighAccuracy: true, // 走 GPS;室内会退化成基站定位,精度可能到 500m timeout: 10000, // 超过 10s 直接失败,让 UI 给"重试"按钮,别无限转圈 maximumAge: 30000 // 30s 内的缓存坐标直接复用,省掉一次 GPS 冷启动 } export function getPosition() { return callNative( () => new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition( (pos) => resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude, accuracy: pos.coords.accuracy, // 单位米,> 100 基本不能用于打卡类场景 ts: pos.timestamp }), (err) => reject(new Error(`geo:${err.code}`)), // 1 无权限 2 定位不可用 3 超时 GEO_OPTS ) }), () => Promise.reject(new Error('geo:not-native')) ) }

err.code一定要透传到业务层做区分:1是权限被拒,要引导去系统设置;2是定位服务没开(GPS 关了或飞行模式);3是超时,可以自动重试一次。三者混在一起提示"定位失败",用户根本不知道该干嘛。

accuracy别丢。打卡、签到类场景常见做法是设一个阈值,比如超过 200 米就提示"信号弱,请到窗边重试",而不是直接把不准的坐标提交上去。还有一个容易忽略的点:GPS 原生返回的是 WGS84 坐标,如果后续要叠加到国内地图服务的图层上,需要转成 GCJ-02,否则会出现稳定偏移,看起来像"定位不准",其实是坐标系没转。需要持续跟踪位置时用watchPosition,但记得把返回的watchId存下来,在onUnmounted里clearWatch,否则页面切走了 GPS 还在跑,耗电投诉马上就来。

3.2 振动:iOS 只认"响一下",pattern 数组会被截断

振动看着最简单,实际上跨平台差异最大。Android 支持navigator.vibrate(ms)和数组 pattern,iOS 侧只支持固定的一次震动,传时长会被忽略。所以振动只能当"辅助反馈",不能当"必须传达的信息"。

// src/native/haptic.js import { callNative } from './bridge' const canVibrate = typeof navigator.vibrate === 'function' // 轻触反馈:15ms 足够,再长就有"手机坏了"的错觉 export function tap() { return callNative( () => { navigator.vibrate(15); return Promise.resolve(true) }, () => { if (canVibrate) navigator.vibrate(15); return Promise.resolve(false) } ) } // 扫码结果用不同节奏区分,别让用户盯屏幕猜成功还是失败 export function buzz(kind = 'ok') { const pattern = kind === 'ok' ? [0, 40, 60, 40] : [0, 120] if (canVibrate) navigator.vibrate(pattern) }

pattern 数组的语义是[等待, 振动, 等待, 振动, ...],单位毫秒,第一个元素通常是 0。[0, 40, 60, 40]是"两短震",[0, 120]是"一长震",用户不用看屏幕就能分辨扫码成功和失败。注意两个边界:部分浏览器要求振动必须由用户手势触发,写在setTimeout里会被静默忽略;不要在循环或滚动回调里连续调,安卓马达会被打死,表现是"振了一下之后半分钟没反应"。

3.3 拍照与相册:destinationType 选错,内存和上传一起崩

相机插件最关键的参数是destinationType。默认值是DATA_URL,返回一整段 base64 字符串,一张 4000x3000 的照片转出来接近 8MB 的字符串,穿过 JS 桥要几百毫秒,低端机上直接 OOM。除非你就是要把图片塞进img.src显示,否则一律用FILE_URI。

// src/native/camera.js import { callNative } from './bridge' const BASE = { quality: 70, // 0-100,70 是体积和观感的平衡点 destinationType: Camera.DestinationType.FILE_URI, // 上传场景必须用 FILE_URI encodingType: Camera.EncodingType.JPEG, mediaType: Camera.MediaType.PICTURE, correctOrientation: true, // 不设这个,安卓竖拍照片会躺倒 saveToPhotoAlbum: false // 存相册在 iOS 上会多要一次权限 } export function takePhoto({ source = 'camera', width = 1280, quality = 70 } = {}) { return callNative( () => new Promise((resolve, reject) => { const opts = { ...BASE, quality, targetWidth: width, // 只给宽,高按比例缩放,避免拉伸变形 sourceType: source === 'album' ? Camera.PictureSourceType.PHOTOLIBRARY : Camera.PictureSourceType.CAMERA } navigator.camera.getPicture( (uri) => resolve({ uri }), (msg) => reject(new Error(`camera:${msg}`)), // 用户取消也会走这里,文案要区分 opts ) }), () => Promise.reject(new Error('camera:not-native')) ) }

targetWidth只给宽度不给高度,插件会按比例算高度;两个都给且比例和原图不一致,会被硬拉伸。correctOrientation: true是必须项,安卓部分机型拍照后写入的 EXIF 方向标记和实际像素不一致,不校正的话竖着拍出来的图在 H5 里是横的。saveToPhotoAlbum: false减少一次 iOS 相册写入权限申请,用户在"仅选图"的场景被要求写相册权限,拒绝率相当高。

拿到file://之后转 Blob 再走普通上传,是最顺的路径:

// file:// 转 Blob,之后用 axios/fetch 正常上传即可 export async function toBlob(fileUri) { const res = await fetch(fileUri) // Cordova WebView 支持直接 fetch file:// if (!res.ok) throw new Error('read-file-failed') return res.blob() }

有的安卓 WebView 对fetch('file://')有限制,会抛TypeError: Failed to fetch。这时的兜底是插件的DATA_URL+FileReader,或者用 file 插件把路径转成cdvfile://再读。选哪条路取决于目标机型的最低版本,我一般先试fetch,联调机上跑不通再切方案,别一上来就上最重的那套。

3.4 扫一扫:formats 白名单和 cancelled 判断

扫码插件的坑集中在两处:把"用户取消"当错误处理,以及formats开太全导致识别慢。

// src/native/scan.js import { callNative } from './bridge' export function scanQr() { return callNative( () => new Promise((resolve, reject) => { cordova.plugins.barcodeScanner.scan( (result) => { if (result.cancelled) return resolve(null) // 用户取消不是异常 resolve({ text: result.text.trim(), format: result.format }) }, (err) => reject(new Error(`scan:${err}`)), { preferFrontCamera: false, showFlipCameraButton: true, showTorchButton: true, torchOn: false, prompt: '把二维码放进框内', resultDisplayDuration: 0, // 0 = 不弹原生结果页,直接回 Vue 处理 formats: 'QR_CODE,DATA_MATRIX', // 只开需要的格式,识别更快更准 orientation: 'portrait' } ) }), () => Promise.reject(new Error('scan:not-native')) ) }

resultDisplayDuration: 0值得单独说。默认值会在扫码成功后弹一个原生小窗显示内容,用户点确认才回到页面,多一次点击、多一层 UI 风格不一致。设 0 之后回调直接进 Vue,成功提示用自己的一致风格做。formats白名单只开业务真正要用的,开全格式会让解码器在每个帧上多跑几轮,弱光下识别率明显下降。

cancelled必须判。不判的话用户点返回键,代码会走到result.text上抛undefined,控制台红一片。另外连续扫码要加节流:把扫码按钮在scan返回前置为disabled,否则用户连点两下会叠加两次原生 Activity,iOS 上表现为弹窗叠弹窗,关不掉。

4. 权限与真机验收:Android 清单、iOS 描述键和 deviceready 时序

插件装好、封装写完,本地cordova serve也能跑,但一上真机就是"点了没反应"。九成问题出在权限声明和时序上。Android 的权限分清单声明和运行时申请两层,iOS 少了描述键不是报错而是直接闪退,deviceready之前调插件则拿到undefined。这三件事在浏览器里全都复现不了,只能在真机上验。

4.1 Android:清单声明加运行时预检,别让按钮点了没反应

清单里的权限用config.xml注入,让cordova build时写进AndroidManifest.xml,比手改platforms/靠谱 —— 后者在下次cordova prepare就被覆盖。

<!-- config.xml:构建时合并进 AndroidManifest --> <platform name="android"> <config-file target="AndroidManifest.xml" parent="/manifest"> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.FLASHLIGHT" /> <uses-feature android:name="android.hardware.camera" android:required="false" /> </config-file> </platform>

uses-feature的required="false"别漏。写成true(或干脆不写,某些插件会默认加),应用商店会把没有摄像头的设备过滤掉,平板用户直接搜不到。相机权限插件内部会自己申请,uses-permission里不写也能弹,但写上有助于审核时说明用途。

运行时权限有一个隐蔽的坑:用户第一次拒绝时勾了"不再询问",之后插件的回调可能永远不返回,按钮点下去毫无反应。稳妥做法是调插件之前先预检。

// 预检相机权限:不给权限就别让按钮可点 const perms = cordova.plugins.permissions perms.checkPermission(perms.CAMERA, (status) => { if (status.hasPermission) return perms.requestPermission(perms.CAMERA, (res) => { if (!res.hasPermission) { alert('相机权限已被关闭,请到系统设置里手动打开') } }, () => {}) }, () => {})

4.2 iOS:描述键缺失是闪退,不是报错

iOS 对隐私权限的检查发生在原生层,Info.plist里少了对应 key,调相机那一刻整个 App 直接退出,控制台留下的信息很少,新手很容易以为是插件坏了。描述文案还要具体,写"需要相机权限"这类空话有被打回的风险。

<!-- config.xml:合并进 *-Info.plist --> <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>

mode="merge"表示已存在就覆盖、不存在就新增,重复执行cordova prepare不会产生重复节点。定位只用前台就申请WhenInUse,别一上来就Always,后者会触发额外的后台审核问询。用户在 iOS 上拒绝过权限之后,再调接口不会弹窗、直接失败,所以错误分支里要给"去设置里打开"的引导,而不是只弹一个"操作失败"。

4.3 deviceready 时序与真机调试:把挂载推迟到事件之后

deviceready之前,navigator.camera、cordova.plugins全都是undefined。常见写法是在main.js里直接createApp(App).mount(),然后在组件的mounted里调插件,快的时候能成,慢的时候(冷启动、低端机)就报 undefined,属于典型的玄学 bug。

// src/main.js —— 先等桥就绪再挂载 import { createApp } from 'vue' import App from './App.vue' import { waitForDevice } from './native/bridge' waitForDevice().then((isNative) => { window.__NATIVE__ = isNative // 组件里据此决定是否显示原生按钮 createApp(App).mount('#app') })

把挂载放在事件之后,首屏会晚几十毫秒,但换来的是"所有组件里调插件都安全"。如果首屏有骨架屏或 loading,这几十毫秒基本感知不到。反过来,如果业务坚持先挂载,那每个调用点都得包一层waitForDevice(),代码里到处是 await,反而更乱。

真机调试用 Chrome 的远程检查:设备连上、cordova run android --device之后,打开chrome://inspect就能看到 WebView 页面,控制台、Network、Element 都能看。原生日志用adb logcat,插件抛的原生异常只在这里出现:

# 只看 WebView 和插件相关的日志,过滤掉系统噪音 adb logcat | grep -iE "chromium|Cordova|camera|barcode"

--livereload可以在改 Vue 代码后自动刷新,但改config.xml或加插件必须重新 build,热更新不会带上原生变更,这一点非常容易混淆。

5. 避坑清单:五个把真机调试拖到怀疑人生的原生调用故障

下面五条都是我在真机上真实撞过的,症状和浏览器表现完全不同,光看控制台根本推不出来。每条按"现象、原因、解决"写,遇到类似的可以先对号入座。

5.1 白屏且控制台干净:base 路径和 history 路由

现象:cordova run之后一片白,控制台没有红色报错,或者只有一行Failed to load resource: net::ERR_FILE_NOT_FOUND。

原因:Vue 构建产物用的是绝对路径,file://协议下/assets/xxx.js被解析到文件系统根目录;同时路由用了 history 模式,首次进入能渲染(如果 index.html 加载成功),一刷新或一跳转就找不到文件。

解决:base: './'(Vue CLI 是publicPath: './'),路由切 hash 模式。改完必须cordova clean再 build,否则www里残留的旧 index.html 还在引用老路径,改了像没改。

5.2 deviceready 永不触发:cordova.js 被当模块打包了

现象:页面能渲染,但所有插件调用都返回 undefined,打日志发现deviceready监听里的代码从来没执行。

原因:把cordova.js写成了import './cordova.js',或者被构建工具当成依赖打进了 bundle。这个文件由壳在运行时注入,提前打包会让它执行在错误的环境里,桥初始化直接失败。

解决:只在index.html里用<script src="cordova.js"></script>,且不要给它加type="module"。构建配置里把cordova.js加进 externals 或用public目录原样拷贝。

5.3 扫码回调不返回或返回两次

现象:点扫码,原生界面出来了,选完结果回到页面,Vue 里的 Promise 一直 pending;或者一次扫码触发两次业务逻辑。

原因:前者的典型是页面被keep-alive缓存,第二次进入时旧的deviceready监听还没解绑,回调被旧闭包吃掉;后者是扫码按钮没做防重,用户连点叠了两个原生 Activity。

解决:扫码入口加disabled状态位,Promise 落定前不允二次触发;onActivated里重新注册、onDeactivated里清理;cancelled分支必须resolve(null)而不是什么都不做。

5.4 拍照返回 content:// 而不是 file://

现象:Android 7 以上,getPicture成功回调拿到的路径是content://media/...,直接丢给fetch报错,上传失败。

原因:Android 7 起强制走 FileProvider,系统相机把结果写进临时 URI,插件原样透传。fetch和XMLHttpRequest都读不了content://。

解决:用 file 插件的resolveLocalFileSystemURL把content://转成可读的本地路径再读取,或者直接把targetWidth压到 800 以内、改用DATA_URL(体积可控时最省事)。验收时一定要在 Android 10 以上的真机上过一遍,模拟器上经常直接给file://,测不出这个问题。

5.5 定位一直超时,室外正常室内必挂

现象:办公室里点定位,十秒后报超时;走到窗边偶尔能出结果,精度几百米。

原因:enableHighAccuracy: true强制走 GPS,室内收不到卫星信号又没降级;maximumAge: 0每次都重新冷启动,第一次定位本来就慢。

解决:把maximumAge设成 30000 复用缓存,timeout设 8000 到 10000,失败后自动重试一次并降级成enableHighAccuracy: false走基站定位。业务上把accuracy分级展示,"精度 500m" 和 "定位失败" 是两种完全不同的用户预期。

6. 进阶收口:一张能力矩阵加一个 useNative 组合式函数

四个能力各写一套调用,组件里迟早会出现if (window.cordova)。收口的办法是先做能力探测矩阵,再用组合式函数把"是否原生、是否授权、降级走什么"三件事一次性返回。探测不要靠 UA 猜,直接看对象存不存在,这是唯一可靠的判断。

能力探测表达式未就绪时的降级
定位!!navigator.geolocation浏览器定位,精度降级并提示
振动typeof navigator.vibrate === 'function'静默跳过,不阻塞主流程
拍照!!(window.navigator.camera)input type="file" accept="image/*"
扫一扫!!(window.cordova && cordova.plugins.barcodeScanner)隐藏入口,提示用原生 App
// src/composables/useNative.js import { ref, computed } from 'vue' import { waitForDevice } from '../native/bridge' import { getPosition } from '../native/geo' import { takePhoto } from '../native/camera' import { scanQr } from '../native/scan' export function useNative() { const isNative = ref(!!window.cordova) // 首帧先给个初值,避免按钮闪一下 const busy = ref(false) waitForDevice().then((v) => { isNative.value = v }) // 统一包一层 busy,防止用户连点触发多个原生 Activity async function run(task) { if (busy.value) return null busy.value = true try { return await task() } finally { busy.value = false } } return { isNative, busy: computed(() => busy.value), locate: () => run(getPosition), photo: (opts) => run(() => takePhoto(opts)), scan: () => run(scanQr) } }

busy用ref加computed暴露成只读,是为了让按钮的disabled绑定有一个统一来源;如果每个组件自己维护一份 loading,扫码叠弹窗的问题迟早复现。run里用finally复位,异常路径也不会把按钮永久锁死。

验证方式我习惯做一个隐藏的"原生自检页",只在开发包里挂上,一次性跑完四项:拿一次定位打accuracy、震一下、拍一张缩略图、扫一个固定二维码,把结果打到页面上。这样换机型、换系统版本时不用逐个功能点,一页就能看出是权限没给、插件没注册、还是桥没起来。

从那以后我每次接新的原生能力,都先把权限矩阵和deviceready时序写进清单,再去动 UI —— 顺序反过来的返工成本,比多写一个自检页高太多。希望帮到你。

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