UniApp 混合开发:Plus API 从基础到7大核心场景实战的完整指南

发布于:2025-09-04 ⋅ 阅读:(19) ⋅ 点赞:(0)

在 UniApp 混合开发中,plus API 是连接前端代码与原生设备能力的核心桥梁。基于 5+ Runtime,它封装了设备硬件、系统交互、网络通信等近百种原生能力,解决了 UniApp 跨端 API 覆盖不足的问题。但直接使用 plus API 常面临兼容性复杂、回调嵌套、权限混乱等痛点。本文将从基础认知出发,结合 7大核心场景 的完整实战案例(含细节补充),提供标准化封装方案,帮你高效落地原生能力。

一、Plus API 基础认知:先搞懂这些前提

1. 什么是 Plus API?

plus 是 UniApp 仅在 App 端(iOS/Android) 暴露的全局对象,基于 DCloud 5+ Runtime 实现,可直接调用原生系统接口。其核心能力分类如下:

  • 设备层:硬件信息(型号、IMEI)、传感器(加速度、陀螺仪)、蓝牙、NFC
  • 系统层:通知、权限、应用管理、文件系统
  • 交互层:弹窗、导航栏、状态栏、本地存储
  • 网络层:网络状态、Socket、HTTP 请求(原生实现)

2. 必须注意的使用限制

  • 环境限制:仅 App 端可用(H5/小程序端 window.plusundefined),需提前做环境判断。
  • 初始化时机:需在 onLaunch 或页面 onLoad 后调用(等待 5+ Runtime 加载完成),可通过 plusready 事件监听:
    // 确保 plus 初始化完成
    const waitPlusReady = () => {
      return new Promise((resolve) => {
        if (window.plus) resolve();
        else document.addEventListener('plusready', resolve, false);
      });
    };
    
  • 权限依赖:多数敏感能力(如 IMEI、定位、蓝牙)需申请系统权限,且 iOS/Android 配置差异较大。
  • 版本兼容性:部分 API 仅支持特定系统版本(如 iOS 13+ 支持蓝牙 BLE,Android 6.0+ 需动态权限)。

二、统一工具类设计

将所有原生能力封装为模块化工具类,解决重复编码、环境判断、跨平台兼容等问题,对外提供简洁的 Promise 接口。

native-utils.js
/**
 * UniApp 原生能力统一工具类
 * 功能模块:环境检测、权限管理、设备信息、网络监听、文件操作、蓝牙通信、
 *          定位服务、应用管理、第三方跳转、版本更新
 * 特点:Promise 化接口、跨平台兼容、自动资源管理
 */
export const NativeUtils = {
  // -------------------------- 基础环境 --------------------------
  /**
   * 检查是否为 App 环境(仅 iOS/Android 可用)
   */
  isAppEnv() {
    return typeof window !== 'undefined' && !!window.plus;
  },

  /**
   * 等待 Plus 环境初始化完成(避免调用时机过早)
   */
  waitReady() {
    return new Promise((resolve) => {
      if (this.isAppEnv()) resolve();
      else document.addEventListener('plusready', resolve, false);
    });
  },

  // -------------------------- 权限管理 --------------------------
  permission: {
    /**
     * 检查权限是否已授予
     * @param {string} perm 权限名称(如 android.permission.ACCESS_FINE_LOCATION)
     */
    check(perm) {
      if (!NativeUtils.isAppEnv()) return Promise.resolve(false);
      return new Promise((resolve) => {
        if (plus.android) {
          plus.android.requestPermissions([perm], 
            (granted) => resolve(granted[0] === 0), // 0 表示授予
            () => resolve(false)
          );
        }
        if (plus.ios) {
          resolve(plus.navigator.checkPermission(perm) === 'authorized');
        }
      });
    },

    /**
     * 申请权限(无权限时触发系统授权弹窗)
     * @param {string} perm 权限名称
     */
    request(perm) {
      if (!NativeUtils.isAppEnv()) return Promise.resolve(false);
      return new Promise((resolve) => {
        if (plus.android) {
          plus.android.requestPermissions([perm], 
            (granted) => resolve(granted[0] === 0),
            () => resolve(false)
          );
        }
        if (plus.ios) {
          plus.navigator.requestPermission(perm, 
            () => resolve(true),
            () => resolve(false)
          );
        }
      });
    }
  },

  // -------------------------- 设备信息 --------------------------
  device: {
    /**
     * 获取设备基础信息(无需权限)
     */
    getBaseInfo() {
      if (!NativeUtils.isAppEnv()) return null;
      return {
        model: plus.device.model, // 设备型号(如 "iPhone 15 Pro")
        vendor: plus.device.vendor, // 厂商(如 "Apple"、"Xiaomi")
        os: plus.os.name, // 系统类型("iOS" 或 "Android")
        osVersion: plus.os.version, // 系统版本(如 "17.0")
        screen: {
          width: plus.screen.resolutionWidth,
          height: plus.screen.resolutionHeight,
          dpi: plus.screen.dpi
        }
      };
    },

    /**
     * 获取设备唯一标识(用于用户绑定,需权限)
     */
    async getUniqueId() {
      if (!NativeUtils.isAppEnv()) throw new Error('仅 App 端支持');

      const platform = plus.os.name;
      if (platform === 'iOS') return plus.runtime.clientid;

      // Android 需要 READ_PHONE_STATE 权限
      const hasPerm = await NativeUtils.permission.check('android.permission.READ_PHONE_STATE');
      if (!hasPerm) {
        const granted = await NativeUtils.permission.request('android.permission.READ_PHONE_STATE');
        if (!granted) throw new Error('需授予"设备信息权限"以获取唯一标识');
      }
      return plus.device.imei || plus.device.meid || plus.runtime.clientid;
    }
  },

  // -------------------------- 网络监听 --------------------------
  network: {
    /**
     * 获取当前网络状态
     */
    getCurrentStatus() {
      if (!NativeUtils.isAppEnv()) {
        return { isConnected: navigator.onLine, type: 'unknown', speed: 0 };
      }

      const netType = plus.networkinfo.getCurrentType();
      const typeMap = {
        [plus.networkinfo.CONNECTION_NONE]: 'none',
        [plus.networkinfo.CONNECTION_WIFI]: 'wifi',
        [plus.networkinfo.CONNECTION_CELL2G]: '2g',
        [plus.networkinfo.CONNECTION_CELL3G]: '3g',
        [plus.networkinfo.CONNECTION_CELL4G]: '4g',
        [plus.networkinfo.CONNECTION_CELL5G]: '5g',
        [plus.networkinfo.CONNECTION_ETHERNET]: 'ethernet'
      };

      // 简单判断网络速度等级
      let speed = 0;
      if (plus.os.name === 'Android') {
        const NetManager = plus.android.importClass('android.net.ConnectivityManager');
        const netManager = plus.android.runtimeMainActivity().getSystemService('connectivity');
        const netInfo = netManager.getActiveNetworkInfo();
        speed = netInfo ? (netInfo.getType() === NetManager.TYPE_WIFI ? 100 : 50) : 0;
      }

      return {
        isConnected: netType !== plus.networkinfo.CONNECTION_NONE,
        type: typeMap[netType] || 'unknown',
        speed
      };
    },

    /**
     * 监听网络状态变化(返回取消监听函数)
     * @param {Function} callback 状态变化回调
     */
    watchStatus(callback) {
      if (!NativeUtils.isAppEnv()) {
        // 非 App 环境用浏览器事件兼容
        const handleOnline = () => callback({ isConnected: true, type: 'unknown' });
        const handleOffline = () => callback({ isConnected: false, type: 'unknown' });
        window.addEventListener('online', handleOnline);
        window.addEventListener('offline', handleOffline);
        return () => {
          window.removeEventListener('online', handleOnline);
          window.removeEventListener('offline', handleOffline);
        };
      }

      // App 环境用 plus 监听
      const updateStatus = () => callback(this.getCurrentStatus());
      updateStatus(); // 初始触发一次
      plus.networkinfo.addEventListener('networkchange', updateStatus);
      return () => plus.networkinfo.removeEventListener('networkchange', updateStatus);
    }
  },

  // -------------------------- 文件操作 --------------------------
  file: {
    /**
     * 获取应用标准目录路径
     * @param {string} type 目录类型:doc/downloads/cache/temp
     */
    getAppDir(type = 'doc') {
      if (!NativeUtils.isAppEnv()) return '';
      const dirMap = {
        doc: '_doc/', // 应用私有,卸载删除
        downloads: '_downloads/', // 系统可访问(如相册可见)
        cache: '_cache/', // 系统可能清理
        temp: '_temp/' // 应用退出后可能清理
      };
      return plus.io.convertLocalFileSystemURL(dirMap[type] || '_doc/');
    },

    /**
     * 写入文件(支持文本/二进制)
     * @param {string} filePath 完整路径
     * @param {string|ArrayBuffer} data 写入内容
     * @param {boolean} isBinary 是否二进制
     */
    writeFile(filePath, data, isBinary = false) {
      if (!NativeUtils.isAppEnv()) throw new Error('仅 App 端支持');

      return new Promise((resolve, reject) => {
        plus.io.resolveLocalFileSystemURL(filePath, (fileEntry) => {
          fileEntry.createWriter((writer) => {
            writer.onwrite = () => resolve(true);
            writer.onerror = (e) => reject(new Error(`写入失败:${e.message}`));
            const writeData = isBinary 
              ? (data instanceof ArrayBuffer ? new Blob([data]) : data)
              : new Blob([data], { type: 'text/plain' });
            writer.write(writeData);
          }, reject);
        }, (e) => {
          // 路径不存在时创建目录
          const dirPath = filePath.substring(0, filePath.lastIndexOf('/'));
          plus.io.resolveLocalFileSystemURL(dirPath, (dirEntry) => {
            dirEntry.getFile(
              filePath.substring(filePath.lastIndexOf('/') + 1),
              { create: true }, // 不存在则创建文件
              () => this.writeFile(filePath, data, isBinary).then(resolve).catch(reject),
              reject
            );
          }, reject);
        });
      });
    },

    /**
     * 读取文件
     * @param {string} filePath 完整路径
     * @param {boolean} isBinary 是否二进制
     */
    readFile(filePath, isBinary = false) {
      if (!NativeUtils.isAppEnv()) throw new Error('仅 App 端支持');

      return new Promise((resolve, reject) => {
        plus.io.resolveLocalFileSystemURL(filePath, (fileEntry) => {
          fileEntry.file((file) => {
            const reader = new FileReader();
            reader.onloadend = (e) => resolve(e.target.result);
            reader.onerror = (e) => reject(new Error(`读取失败:${e.message}`));
            isBinary ? reader.readAsArrayBuffer(file) : reader.readAsText(file);
          }, reject);
        }, reject);
      });
    },

    /**
     * 下载文件(带进度监听)
     * @param {string} url 远程URL
     * @param {string} savePath 保存路径
     * @param {Function} progressCallback 进度回调
     */
    downloadFile(url, savePath, progressCallback) {
      if (!NativeUtils.isAppEnv()) throw new Error('仅 App 端支持');

      return new Promise((resolve, reject) => {
        const downloadTask = plus.downloader.createDownload(url, { filename: savePath }, 
          (download, status) => {
            if (status === 200) resolve(download.filename);
            else reject(new Error(`下载失败:状态码 ${status}`));
          }
        );

        // 监听下载进度
        downloadTask.addEventListener('statechanged', (task) => {
          if (task.state === 2 && progressCallback) {
            const progress = Math.floor((task.downloadedSize / task.totalSize) * 100);
            progressCallback(progress);
          }
        });
        downloadTask.start();
      });
    }
  },

  // -------------------------- 蓝牙通信 --------------------------
  bluetooth: {
    _bluetooth: null, // 蓝牙实例
    _connectedDevice: null, // 已连接设备ID
    _onDataReceived: null, // 数据接收回调

    /**
     * 初始化蓝牙适配器
     */
    async init() {
      if (!NativeUtils.isAppEnv()) throw new Error('仅 App 端支持');
      if (this._bluetooth) return;

      // 检查蓝牙权限
      const perm = plus.os.name === 'Android' ? 'android.permission.BLUETOOTH' : 'bluetooth';
      const hasPerm = await NativeUtils.permission.check(perm);
      if (!hasPerm) {
        const granted = await NativeUtils.permission.request(perm);
        if (!granted) throw new Error('需授予蓝牙权限');
      }

      this._bluetooth = plus.bluetooth;
    },

    /**
     * 开始搜索蓝牙设备
     * @param {Function} onDeviceFound 设备发现回调
     */
    startScan(onDeviceFound) {
      if (!this._bluetooth) throw new Error('蓝牙未初始化');

      return new Promise((resolve, reject) => {
        this._bluetooth.startBluetoothDevicesDiscovery({
          services: [], // 搜索所有服务
          success: () => {
            this._bluetooth.onBluetoothDeviceFound((device) => {
              // 过滤无名称或已连接设备
              if (device.name && !device.connected) {
                onDeviceFound(device);
              }
            });
            resolve();
          },
          fail: (err) => reject(new Error(`搜索失败:${err.errMsg}`))
        });
      });
    },

    /**
     * 停止搜索设备
     */
    stopScan() {
      if (this._bluetooth) {
        this._bluetooth.stopBluetoothDevicesDiscovery();
      }
    },

    /**
     * 连接蓝牙设备
     * @param {string} deviceId 设备ID
     * @param {Function} onDataReceived 接收数据回调
     */
    connectDevice(deviceId, onDataReceived) {
      if (!this._bluetooth) throw new Error('蓝牙未初始化');

      return new Promise((resolve, reject) => {
        this._bluetooth.createBLEConnection({
          deviceId,
          success: () => {
            this._connectedDevice = deviceId;
            this._onDataReceived = onDataReceived;
            
            // 监听数据接收
            this._bluetooth.onBLECharacteristicValueChange((res) => {
              const data = String.fromCharCode.apply(null, new Uint8Array(res.value));
              this._onDataReceived && this._onDataReceived(data);
            });
            resolve();
          },
          fail: (err) => reject(new Error(`连接失败:${err.errMsg}`))
        });
      });
    },

    /**
     * 向设备发送数据
     * @param {string} serviceId 服务UUID
     * @param {string} charId 特征值UUID
     * @param {string} data 发送数据
     */
    sendData(serviceId, charId, data) {
      if (!this._connectedDevice) throw new Error('未连接设备');

      return new Promise((resolve, reject) => {
        // 字符串转ArrayBuffer
        const buffer = new ArrayBuffer(data.length);
        const view = new Uint8Array(buffer);
        for (let i = 0; i < data.length; i++) {
          view[i] = data.charCodeAt(i);
        }

        this._bluetooth.writeBLECharacteristicValue({
          deviceId: this._connectedDevice,
          serviceId,
          characteristicId: charId,
          value: buffer,
          success: resolve,
          fail: (err) => reject(new Error(`发送失败:${err.errMsg}`))
        });
      });
    },

    /**
     * 断开设备连接
     */
    disconnect() {
      if (this._connectedDevice && this._bluetooth) {
        this._bluetooth.closeBLEConnection({ deviceId: this._connectedDevice });
        this._connectedDevice = null;
        this._onDataReceived = null;
      }
    }
  },

  // -------------------------- 定位服务 --------------------------
  location: {
    _watcher: null, // 定位监听器

    /**
     * 单次定位(用于签到等场景)
     * @param {Object} options 定位配置
     */
    async getCurrent(options = {}) {
      if (!NativeUtils.isAppEnv()) throw new Error('仅 App 端支持');

      const { provider = 'auto', accuracy = 1 } = options;
      const perm = plus.os.name === 'Android' 
        ? 'android.permission.ACCESS_FINE_LOCATION' 
        : 'location';

      // 检查定位权限
      const hasPerm = await NativeUtils.permission.check(perm);
      if (!hasPerm) {
        const granted = await NativeUtils.permission.request(perm);
        if (!granted) throw new Error('需授予定位权限');
      }

      return new Promise((resolve, reject) => {
        plus.geolocation.getCurrentPosition(
          (position) => {
            resolve({
              lat: position.coords.latitude,
              lng: position.coords.longitude,
              accuracy: position.coords.accuracy,
              address: position.addresses || '未知地址',
              time: new Date(position.timestamp).toLocaleString()
            });
          },
          (err) => reject(new Error(`定位失败:${err.message}`)),
          { provider, accuracy, maximumAge: 30000 } // 30秒缓存
        );
      });
    },

    /**
     * 连续定位(用于轨迹追踪)
     * @param {Function} callback 位置变化回调
     * @param {number} interval 定位间隔(毫秒)
     */
    async watch(callback, interval = 5000) {
      if (!NativeUtils.isAppEnv()) throw new Error('仅 App 端支持');

      const perm = plus.os.name === 'Android' 
        ? 'android.permission.ACCESS_FINE_LOCATION' 
        : 'location';
      const hasPerm = await NativeUtils.permission.check(perm);
      if (!hasPerm) {
        const granted = await NativeUtils.permission.request(perm);
        if (!granted) throw new Error('需授予定位权限');
      }

      this._watcher = plus.geolocation.watchPosition(
        (position) => callback({
          lat: position.coords.latitude,
          lng: position.coords.longitude,
          accuracy: position.coords.accuracy
        }),
        (err) => console.error('定位异常:', err),
        { provider: 'auto', accuracy: 1, interval }
      );

      return () => this.stopWatch(); // 返回停止函数
    },

    /**
     * 停止连续定位
     */
    stopWatch() {
      if (this._watcher) {
        plus.geolocation.clearWatch(this._watcher);
        this._watcher = null;
      }
    }
  },

  // -------------------------- 应用管理 --------------------------
  app: {
    /**
     * 获取应用版本信息
     */
    getVersionInfo() {
      if (!NativeUtils.isAppEnv()) {
        return { versionName: 'unknown', versionCode: 0 };
      }
      return {
        versionName: plus.runtime.version, // 版本名称(如 "1.0.0")
        versionCode: plus.runtime.versionCode // 版本号(数字)
      };
    },

    /**
     * 检查第三方应用是否安装
     * @param {Object} options 应用标识(packageName/scheme)
     */
    checkInstalled(options) {
      if (!NativeUtils.isAppEnv()) throw new Error('仅 App 端支持');

      return new Promise((resolve) => {
        const checkOpts = plus.os.name === 'Android'
          ? { pname: options.packageName }
          : { action: options.scheme };
        plus.runtime.checkApplication(checkOpts, (exists) => resolve(!!exists));
      });
    },

    /**
     * 打开第三方应用
     * @param {Object} options 应用标识 + 参数
     */
    openThirdApp(options) {
      if (!NativeUtils.isAppEnv()) throw new Error('仅 App 端支持');

      return new Promise((resolve, reject) => {
        const { packageName, scheme, params = '' } = options;
        let launchOpts;

        if (plus.os.name === 'Android') {
          launchOpts = { pname: packageName, extra: { action: 'android.intent.action.VIEW', data: params } };
        } else {
          if (!scheme) reject(new Error('iOS 需指定 URL Scheme'));
          launchOpts = { action: `${scheme}${params}` };
        }

        plus.runtime.launchApplication(launchOpts, (e) => {
          reject(new Error(`打开失败:${e.message}`));
        });
        setTimeout(resolve, 500); // 延迟确认成功
      });
    },

    /**
     * 检查版本更新
     * @param {string} checkUrl 版本检查接口
     */
    checkUpdate(checkUrl) {
      if (!NativeUtils.isAppEnv()) throw new Error('仅 App 端支持');

      return new Promise((resolve, reject) => {
        uni.request({
          url: checkUrl,
          method: 'GET',
          success: (res) => {
            const { code, data } = res.data;
            if (code !== 200) {
              reject(new Error('版本检查接口异常'));
              return;
            }

            const localVer = plus.runtime.versionCode;
            const remoteVer = data.versionCode;

            // 有新版本
            if (remoteVer > localVer) {
              resolve({
                hasUpdate: true,
                versionName: data.versionName,
                content: data.updateContent,
                url: data.downloadUrl,
                force: data.forceUpdate || false
              });
            } else {
              resolve({ hasUpdate: false });
            }
          },
          fail: () => reject(new Error('版本检查网络异常'))
        });
      });
    },

    /**
     * 安装应用更新(Android APK)
     * @param {string} apkPath APK文件路径
     */
    installUpdate(apkPath) {
      if (!NativeUtils.isAppEnv()) throw new Error('仅 App 端支持');
      if (plus.os.name !== 'Android') throw new Error('仅 Android 支持APK安装');

      return new Promise((resolve, reject) => {
        plus.io.resolveLocalFileSystemURL(apkPath, () => {
          plus.runtime.install(
            apkPath,
            { force: true },
            () => resolve(),
            (e) => reject(new Error(`安装失败:${e.message}`))
          );
        }, (e) => reject(new Error(`文件不存在:${e.message}`)));
      });
    }
  }
};

// 常用第三方应用配置(避免硬编码)
export const ThirdAppConfig = {
  wechat: { name: '微信', packageName: 'com.tencent.mm', scheme: 'weixin://' },
  alipay: { name: '支付宝', packageName: 'com.eg.android.AlipayGphone', scheme: 'alipay://' },
  qq: { name: 'QQ', packageName: 'com.tencent.mobileqq', scheme: 'mqq://' },
  gaodeMap: { name: '高德地图', packageName: 'com.autonavi.minimap', scheme: 'amapuri://' },
  baiduMap: { name: '百度地图', packageName: 'com.baidu.BaiduMap', scheme: 'baidumap://' }
};

三、7大核心场景实战案例

场景1:设备信息采集

需求:获取设备型号、系统版本、唯一标识等信息,用于用户账号与设备绑定(防止多设备登录)或设备数据分析。
业务使用示例(用户绑定/设备统计)
<template>
  <view class="container">
    <!-- 加载状态 -->
    <view class="loading" v-if="isLoading">
      <uni-loading-icon size="24"></uni-loading-icon>
      <text class="loading-text">获取设备信息中...</text>
    </view>

    <!-- 设备信息卡片 -->
    <view class="info-card" v-else>
      <view class="card-title">设备信息</view>
      
      <view class="info-item">
        <text class="item-label">设备型号:</text>
        <text class="item-value">{{ deviceInfo.model || '未知' }}</text>
      </view>
      <view class="info-item">
        <text class="item-label">厂商:</text>
        <text class="item-value">{{ deviceInfo.vendor || '未知' }}</text>
      </view>
      <view class="info-item">
        <text class="item-label">系统:</text>
        <text class="item-value">{{ deviceInfo.os }} {{ deviceInfo.osVersion }}</text>
      </view>
      <view class="info-item">
        <text class="item-label">屏幕:</text>
        <text class="item-value">{{ deviceInfo.screen.width }}×{{ deviceInfo.screen.height }}</text>
      </view>
      <view class="info-item" v-if="uniqueId">
        <text class="item-label">设备标识:</text>
        <text class="item-value">{{ uniqueId }}</text>
      </view>

      <!-- 绑定按钮 -->
      <button class="bind-btn" @click="bindDevice" :disabled="isBinding">
        {{ isBinding ? '绑定中...' : '绑定当前设备' }}
      </button>
    </view>
  </view>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { NativeUtils } from '@/utils/native-utils.js';
import { showToast } from '@dcloudio/uni-app';
import UniLoadingIcon from '@dcloudio/uni-ui/lib/uni-loading-icon/uni-loading-icon.vue';

// 状态管理
const isLoading = ref(true);
const isBinding = ref(false);
const deviceInfo = ref({ screen: {} });
const uniqueId = ref('');

// 初始化获取设备信息
onMounted(async () => {
  await NativeUtils.waitReady(); // 等待Plus环境就绪
  try {
    // 获取基础信息(无需权限)
    deviceInfo.value = NativeUtils.device.getBaseInfo() || { screen: {} };
    // 获取唯一标识(需权限)
    uniqueId.value = await NativeUtils.device.getUniqueId();
  } catch (err) {
    showToast({ title: err.message, icon: 'none' });
    console.error('设备信息获取失败:', err);
  } finally {
    isLoading.value = false;
  }
});

// 绑定设备到用户账号
const bindDevice = async () => {
  if (!uniqueId.value) {
    showToast({ title: '设备标识获取失败,无法绑定', icon: 'none' });
    return;
  }

  isBinding.value = true;
  try {
    // 模拟后端接口请求
    const res = await uni.request({
      url: '/api/user/bind-device',
      method: 'POST',
      data: {
        userId: uni.getStorageSync('userId'),
        deviceId: uniqueId.value,
        deviceInfo: deviceInfo.value
      }
    });

    if (res.data.code === 200) {
      showToast({ title: '设备绑定成功' });
    } else {
      showToast({ title: res.data.msg || '绑定失败', icon: 'none' });
    }
  } catch (err) {
    showToast({ title: '网络异常,绑定失败', icon: 'none' });
  } finally {
    isBinding.value = false;
  }
};
</script>

<style scoped>
.container {
  padding: 20rpx;
  background-color: #f5f5f5;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.loading {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-top: 50vh;
  transform: translateY(-50%);
}

.loading-text {
  margin-top: 16rpx;
  font-size: 28rpx;
  color: #666;
}

.info-card {
  width: 100%;
  background: #fff;
  border-radius: 20rpx;
  padding: 30rpx;
  box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
}

.card-title {
  font-size: 32rpx;
  font-weight: bold;
  color: #333;
  margin-bottom: 30rpx;
  padding-bottom: 16rpx;
  border-bottom: 1px solid #f0f0f0;
}

.info-item {
  display: flex;
  flex-wrap: wrap;
  margin-bottom: 24rpx;
  font-size: 28rpx;
}

.item-label {
  color: #666;
  width: 200rpx;
}

.item-value {
  color: #333;
  flex: 1;
  word-break: break-all;
}

.bind-btn {
  width: 100%;
  height: 88rpx;
  line-height: 88rpx;
  background-color: #007aff;
  color: #fff;
  font-size: 30rpx;
  border-radius: 12rpx;
  margin-top: 20rpx;
}
</style>

关键功能
  • 自动区分 App/非 App 环境,非 App 环境优雅降级
  • 基础信息(型号、系统)无需权限,直接获取
  • 唯一标识需权限,自动申请并处理授权失败场景
  • 绑定功能模拟与后端交互,实际项目中替换为真实接口
避坑指南
  • Android 10+ 需在 manifest.json 中声明 READ_PHONE_STATE 权限
  • iOS 无法获取 IMEI,工具类已用 plus.runtime.clientid 替代(需绑定 DCloud 账号)

场景2:网络状态监听

需求:实时监听网络状态,WiFi 环境加载高清资源,移动网络加载缩略图,离线时显示提示并禁用网络功能。
业务使用示例(资源适配/离线提示)
<template>
  <view class="container">
    <!-- 离线提示条 -->
    <view class="offline-tip" v-if="!networkStatus.isConnected">
      ⚠️ 当前无网络连接,仅可查看缓存内容
    </view>

    <!-- 网络状态显示 -->
    <view class="status-card">
      <view class="card-title">网络状态</view>
      <view class="status-item">
        <text class="item-label">连接状态:</text>
        <text class="item-value">{{ networkStatus.isConnected ? '已连接' : '未连接' }}</text>
      </view>
      <view class="status-item">
        <text class="item-label">网络类型:</text>
        <text class="item-value">{{ networkStatus.type || '未知' }}</text>
      </view>
      <view class="status-item">
        <text class="item-label">速度等级:</text>
        <text class="item-value">{{ networkStatus.speed ? networkStatus.speed + ' Mbps' : '未知' }}</text>
      </view>
    </view>

    <!-- 动态资源加载 -->
    <view class="resource-card">
      <view class="card-title">自适应资源</view>
      <image :src="imageUrl" mode="widthFix" class="dynamic-image"></image>
      <text class="image-desc" v-if="imageUrl">
        {{ networkStatus.type === 'wifi' ? '高清图(WiFi环境)' : '缩略图(移动网络)' }}
      </text>
    </view>

    <!-- 网络操作按钮 -->
    <button class="network-btn" @click="fetchData" :disabled="!networkStatus.isConnected">
      {{ !networkStatus.isConnected ? '离线状态,无法请求' : '请求网络数据' }}
    </button>
  </view>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { NativeUtils } from '@/utils/native-utils.js';
import { showToast } from '@dcloudio/uni-app';

// 状态管理
const networkStatus = ref({ isConnected: true, type: 'unknown', speed: 0 });
const imageUrl = ref('');
let cancelNetworkWatch = null; // 取消监听的函数

// 初始化网络监听
onMounted(async () => {
  await NativeUtils.waitReady();
  // 注册网络监听,返回取消函数
  cancelNetworkWatch = NativeUtils.network.watchStatus((status) => {
    networkStatus.value = status;
    // 网络变化时更新图片质量
    updateImageUrl(status.type);
    // 离线提示
    if (!status.isConnected) {
      showToast({ title: '网络已断开', icon: 'none' });
    }
  });
});

// 页面卸载时取消监听(避免内存泄漏)
onUnmounted(() => {
  if (cancelNetworkWatch) cancelNetworkWatch();
});

// 根据网络类型更新图片URL
const updateImageUrl = (netType) => {
  const baseUrl = 'https://picsum.photos/';
  // WiFi加载高清图,移动网络加载缩略图
  imageUrl.value = netType === 'wifi' 
    ? `${baseUrl}800/600?random=${Math.random()}` 
    : `${baseUrl}400/300?random=${Math.random()}`;
};

// 请求网络数据(离线禁用)
const fetchData = async () => {
  try {
    showToast({ title: '请求中...', icon: 'loading' });
    const res = await uni.request({
      url: 'https://api.example.com/data',
      method: 'GET'
    });
    showToast({ title: '请求成功', icon: 'success' });
    console.log('网络数据:', res.data);
  } catch (err) {
    showToast({ title: '请求失败', icon: 'none' });
  }
};
</script>

<style scoped>
.container {
  padding: 20rpx;
  background-color: #f5f5f5;
  min-height: 100vh;
  padding-top: 80rpx; /* 给离线提示条留空间 */
}

.offline-tip {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 70rpx;
  line-height: 70rpx;
  background-color: #fff3cd;
  color: #d39e00;
  font-size: 28rpx;
  text-align: center;
  z-index: 999;
}

.status-card, .resource-card {
  background: #fff;
  padding: 30rpx;
  border-radius: 16rpx;
  margin-bottom: 30rpx;
}

.card-title {
  font-size: 32rpx;
  font-weight: bold;
  color: #333;
  margin-bottom: 24rpx;
  padding-bottom: 16rpx;
  border-bottom: 1px solid #f0f0f0;
}

.status-item {
  display: flex;
  margin-bottom: 16rpx;
  font-size: 28rpx;
}

.item-label {
  color: #666;
  width: 200rpx;
}

.item-value {
  color: #333;
  flex: 1;
}

.dynamic-image {
  width: 100%;
  border-radius: 12rpx;
}

.image-desc {
  display: block;
  text-align: center;
  margin-top: 16rpx;
  font-size: 26rpx;
  color: #666;
}

.network-btn {
  width: 100%;
  height: 88rpx;
  line-height: 88rpx;
  background-color: #007aff;
  color: #fff;
  font-size: 30rpx;
  border-radius: 12rpx;
}

.network-btn:disabled {
  background-color: #ccc;
  color: #fff;
}
</style>

关键功能
  • 实时监听网络状态变化(连接/断开、类型切换)
  • 自动根据网络类型切换图片质量(WiFi/移动网络)
  • 离线时禁用网络操作按钮,显示全局提示
  • 非 App 环境自动兼容浏览器 online/offline 事件
避坑指南
  • 网络类型判断需注意不同设备的兼容性(部分设备可能返回 unknown
  • 动态资源需加随机参数避免缓存,确保网络切换时加载新资源

场景3:文件离线缓存

需求:将接口数据缓存到本地,离线时读取缓存;下载图片到本地目录,支持离线查看。
业务使用示例(数据持久化/资源预加载)
<template>
  <view class="container">
    <!-- 数据缓存区域 -->
    <view class="section">
      <view class="section-title">接口数据缓存</view>
      <button class="operate-btn" @click="cacheApiData">缓存用户信息</button>
      <button class="operate-btn" @click="readCacheData" style="margin-top: 16rpx;">读取缓存信息</button>
      
      <view class="cache-result" v-if="cacheResult">
        <view class="result-title">缓存内容:</view>
        <view class="result-content">{{ cacheResult }}</view>
      </view>
    </view>

    <!-- 图片下载区域 -->
    <view class="section">
      <view class="section-title">图片离线下载</view>
      <button class="operate-btn" @click="downloadImage">下载示例图片</button>
      <progress :percent="downloadProgress" v-if="downloadProgress > 0 && downloadProgress < 100" class="progress"></progress>
      
      <image :src="localImageUrl" mode="widthFix" class="downloaded-image" v-if="localImageUrl"></image>
      <view class="image-path" v-if="localImageUrl">
        保存路径:{{ imageSavePath }}
      </view>
    </view>
  </view>
</template>

<script setup>
import { ref } from 'vue';
import { NativeUtils } from '@/utils/native-utils.js';
import { showToast } from '@dcloudio/uni-app';

// 状态管理
const cacheResult = ref('');
const downloadProgress = ref(0);
const localImageUrl = ref('');
const imageSavePath = ref('');

// 缓存接口数据(模拟用户信息)
const cacheApiData = async () => {
  try {
    // 模拟接口返回的用户数据
    const userData = {
      userId: '10086',
      username: '张三',
      avatar: 'https://picsum.photos/200/200',
      lastLogin: new Date().toLocaleString()
    };

    // 获取文档目录路径(永久存储)
    const docDir = NativeUtils.file.getAppDir('doc');
    const filePath = `${docDir}user_info.json`;

    // 写入JSON文件
    await NativeUtils.file.writeFile(filePath, JSON.stringify(userData));
    showToast({ title: '用户信息缓存成功' });
  } catch (err) {
    showToast({ title: err.message, icon: 'none' });
  }
};

// 读取缓存数据
const readCacheData = async () => {
  try {
    const docDir = NativeUtils.file.getAppDir('doc');
    const filePath = `${docDir}user_info.json`;

    // 读取文本文件
    const dataStr = await NativeUtils.file.readFile(filePath);
    const userData = JSON.parse(dataStr);
    cacheResult.value = JSON.stringify(userData, null, 2); // 格式化显示
    showToast({ title: '缓存读取成功' });
  } catch (err) {
    cacheResult.value = '';
    showToast({ title: '缓存不存在或读取失败', icon: 'none' });
  }
};

// 下载图片到本地
const downloadImage = async () => {
  try {
    const imageUrl = 'https://picsum.photos/800/600';
    // 获取下载目录(系统可访问)
    const downloadDir = NativeUtils.file.getAppDir('downloads');
    const timestamp = new Date().getTime();
    const savePath = `${downloadDir}offline-image-${timestamp}.jpg`;
    imageSavePath.value = savePath;

    downloadProgress.value = 0;
    showToast({ title: '开始下载...', icon: 'loading' });

    // 下载图片(带进度监听)
    const localPath = await NativeUtils.file.downloadFile(
      imageUrl,
      savePath,
      (progress) => {
        downloadProgress.value = progress;
      }
    );

    downloadProgress.value = 100;
    localImageUrl.value = localPath; // 显示本地图片
    showToast({ title: '图片下载完成' });

    // 1秒后重置进度条
    setTimeout(() => {
      downloadProgress.value = 0;
    }, 1000);
  } catch (err) {
    downloadProgress.value = 0;
    showToast({ title: err.message, icon: 'none' });
  }
};
</script>

<style scoped>
.container {
  padding: 20rpx;
  background-color: #f5f5f5;
  min-height: 100vh;
}

.section {
  background: #fff;
  padding: 30rpx;
  border-radius: 16rpx;
  margin-bottom: 30rpx;
}

.section-title {
  font-size: 32rpx;
  font-weight: bold;
  color: #333;
  margin-bottom: 30rpx;
  padding-bottom: 16rpx;
  border-bottom: 1px solid #f0f0f0;
}

.operate-btn {
  width: 100%;
  height: 88rpx;
  line-height: 88rpx;
  background-color: #007aff;
  color: #fff;
  font-size: 30rpx;
  border-radius: 12rpx;
}

.cache-result {
  margin-top: 30rpx;
  padding: 20rpx;
  background-color: #f9f9f9;
  border-radius: 12rpx;
  font-size: 26rpx;
  color: #666;
}

.result-title {
  font-weight: bold;
  color: #333;
  margin-bottom: 10rpx;
  display: block;
}

.result-content {
  white-space: pre-wrap;
  word-break: break-all;
}

.progress {
  width: 100%;
  height: 16rpx;
  margin: 20rpx 0;
}

.downloaded-image {
  width: 100%;
  border-radius: 12rpx;
  margin-top: 16rpx;
}

.image-path {
  font-size: 24rpx;
  color: #666;
  margin-top: 16rpx;
  padding: 16rpx;
  background-color: #f9f9f9;
  border-radius: 8rpx;
  word-break: break-all;
}
</style>

关键功能
  • 区分不同文件目录(_doc 私有存储、_downloads 系统可见)
  • 支持文本(JSON)和二进制(图片)文件操作
  • 下载文件带进度监听,提升用户体验
  • 自动处理路径不存在场景(创建目录和文件)
避坑指南
  • 大文件(>100MB)建议分块下载,避免内存溢出
  • Android 10+ 访问外部存储需在 manifest.json 中添加 android:requestLegacyExternalStorage="true"
  • iOS 沙盒机制限制,_downloads 目录文件需通过 plus.share.sendWithSystem 才能分享到系统相册

场景4:蓝牙设备通信

需求:搜索并连接蓝牙设备(如智能手环、蓝牙打印机),实现数据收发(读取设备状态、发送控制指令)。
业务使用示例(智能硬件交互)
<template>
  <view class="container">
    <!-- 蓝牙控制区 -->
    <view class="section">
      <view class="section-title">蓝牙控制</view>
      <button class="control-btn" @click="initBluetooth" :disabled="isProcessing">
        {{ isBluetoothInit ? '已初始化' : '初始化蓝牙' }}
      </button>
      <button class="control-btn" @click="toggleScan" :disabled="!isBluetoothInit || isProcessing">
        {{ isScanning ? '停止搜索' : '搜索设备' }}
      </button>
      <button class="control-btn" @click="disconnectDevice" :disabled="!connectedDevice || isProcessing">
        断开连接
      </button>
    </view>

    <!-- 设备列表 -->
    <view class="section" v-if="isBluetoothInit">
      <view class="section-title">
        附近设备 {{ isScanning ? '(搜索中...)' : '' }}
      </view>
      <view class="device-list">
        <view 
          class="device-item" 
          v-for="device in foundDevices" 
          :key="device.deviceId"
          @click="connectToDevice(device)"
          :class="{ 'connected': device.deviceId === connectedDevice?.deviceId }"
        >
          <view class="device-name">{{ device.name || '未知设备' }}</view>
          <view class="device-id">{{ device.deviceId }}</view>
        </view>
      </view>
    </view>

    <!-- 数据通信区 -->
    <view class="section" v-if="connectedDevice">
      <view class="section-title">设备通信</view>
      <view class="received-data">
        <view class="data-label">接收数据:</view>
        <view class="data-content">{{ receivedData || '等待数据...' }}</view>
      </view>
      <view class="send-area">
        <input 
          type="text" 
          class="send-input" 
          v-model="sendText" 
          placeholder="输入要发送的内容"
        />
        <button class="send-btn" @click="sendDataToDevice">发送</button>
      </view>
    </view>
  </view>
</template>

<script setup>
import { ref, onUnmounted } from 'vue';
import { NativeUtils } from '@/utils/native-utils.js';
import { showToast } from '@dcloudio/uni-app';

// 状态管理
const isProcessing = ref(false);
const isBluetoothInit = ref(false);
const isScanning = ref(false);
const foundDevices = ref([]);
const connectedDevice = ref(null);
const receivedData = ref('');
const sendText = ref('');

// 页面卸载时清理资源
onUnmounted(() => {
  disconnectDevice();
  if (isScanning.value) {
    NativeUtils.bluetooth.stopScan();
  }
});

// 初始化蓝牙
const initBluetooth = async () => {
  if (isBluetoothInit.value) return;
  
  isProcessing.value = true;
  try {
    await NativeUtils.bluetooth.init();
    isBluetoothInit.value = true;
    showToast({ title: '蓝牙初始化成功' });
  } catch (err) {
    showToast({ title: err.message, icon: 'none' });
  } finally {
    isProcessing.value = false;
  }
};

// 切换设备搜索状态
const toggleScan = async () => {
  if (isScanning.value) {
    // 停止搜索
    NativeUtils.bluetooth.stopScan();
    isScanning.value = false;
    showToast({ title: '已停止搜索' });
  } else {
    // 开始搜索
    foundDevices.value = [];
    isScanning.value = true;
    try {
      await NativeUtils.bluetooth.startScan((device) => {
        // 去重添加设备
        const exists = foundDevices.value.some(d => d.deviceId === device.deviceId);
        if (!exists) {
          foundDevices.value.push(device);
        }
      });
      showToast({ title: '开始搜索设备' });
    } catch (err) {
      isScanning.value = false;
      showToast({ title: err.message, icon: 'none' });
    }
  }
};

// 连接设备
const connectToDevice = async (device) => {
  if (isProcessing.value || connectedDevice.value) return;
  
  isProcessing.value = true;
  try {
    // 停止搜索再连接
    if (isScanning.value) {
      NativeUtils.bluetooth.stopScan();
      isScanning.value = false;
    }

    // 连接设备并注册数据接收回调
    await NativeUtils.bluetooth.connectDevice(device.deviceId, (data) => {
      receivedData.value = data;
      showToast({ title: '收到新数据', icon: 'none' });
    });

    connectedDevice.value = device;
    showToast({ title: `已连接 ${device.name}` });
  } catch (err) {
    showToast({ title: err.message, icon: 'none' });
  } finally {
    isProcessing.value = false;
  }
};

// 断开连接
const disconnectDevice = () => {
  if (!connectedDevice.value) return;
  
  NativeUtils.bluetooth.disconnect();
  showToast({ title: `已断开 ${connectedDevice.value.name}` });
  connectedDevice.value = null;
  receivedData.value = '';
};

// 发送数据到设备
const sendDataToDevice = async () => {
  if (!sendText.value.trim()) {
    showToast({ title: '请输入发送内容', icon: 'none' });
    return;
  }

  try {
    // 实际项目中需替换为设备对应的 serviceId 和 characteristicId
    const serviceId = '0000ffe0-0000-1000-8000-00805f9b34fb';
    const charId = '0000ffe1-0000-1000-8000-00805f9b34fb';
    
    await NativeUtils.bluetooth.sendData(serviceId, charId, sendText.value);
    showToast({ title: '发送成功' });
    sendText.value = '';
  } catch (err) {
    showToast({ title: err.message, icon: 'none' });
  }
};
</script>

<style scoped>
.container {
  padding: 20rpx;
  background-color: #f5f5f5;
  min-height: 100vh;
}

.section {
  background: #fff;
  padding: 30rpx;
  border-radius: 16rpx;
  margin-bottom: 30rpx;
}

.section-title {
  font-size: 32rpx;
  font-weight: bold;
  color: #333;
  margin-bottom: 30rpx;
  padding-bottom: 16rpx;
  border-bottom: 1px solid #f0f0f0;
}

.control-btn {
  width: 100%;
  height: 88rpx;
  line-height: 88rpx;
  background-color: #007aff;
  color: #fff;
  font-size: 30rpx;
  border-radius: 12rpx;
  margin-bottom: 16rpx;
}

.device-list {
  max-height: 400rpx;
  overflow-y: auto;
}

.device-item {
  padding: 20rpx;
  border-bottom: 1px solid #f0f0f0;
  cursor: pointer;
}

.device-item:last-child {
  border-bottom: none;
}

.device-item.connected {
  background-color: #e6f7ff;
}

.device-name {
  font-size: 28rpx;
  font-weight: bold;
  color: #333;
  margin-bottom: 8rpx;
}

.device-id {
  font-size: 24rpx;
  color: #666;
  word-break: break-all;
}

.received-data {
  padding: 20rpx;
  background-color: #f9f9f9;
  border-radius: 12rpx;
  margin-bottom: 24rpx;
}

.data-label {
  font-size: 26rpx;
  font-weight: bold;
  color: #333;
  margin-bottom: 8rpx;
  display: block;
}

.data-content {
  font-size: 26rpx;
  color: #666;
  min-height: 60rpx;
}

.send-area {
  display: flex;
  gap: 16rpx;
}

.send-input {
  flex: 1;
  height: 88rpx;
  line-height: 88rpx;
  border: 1px solid #eee;
  border-radius: 12rpx;
  padding: 0 20rpx;
  font-size: 28rpx;
}

.send-btn {
  width: 160rpx;
  height: 88rpx;
  line-height: 88rpx;
  background-color: #007aff;
  color: #fff;
  font-size: 28rpx;
  border-radius: 12rpx;
}
</style>

关键功能
  • 蓝牙初始化自动申请权限,处理授权失败场景
  • 设备搜索去重显示,避免重复列表项
  • 连接状态管理,支持断开重连
  • 数据收发封装,自动处理字符串与二进制转换
避坑指南
  • 不同设备的 serviceId 和 characteristicId 不同,需根据硬件文档修改
  • 蓝牙连接易受环境干扰,建议添加超时重试机制
  • iOS 蓝牙权限需要在 info.plist 中添加 NSBluetoothAlwaysUsageDescription 说明

场景5:定位服务

需求:实现用户签到(获取当前位置并提交)和实时轨迹追踪(如外卖配送、物流跟踪)。
业务使用示例(签到/轨迹追踪)
<template>
  <view class="container">
    <!-- 签到区域 -->
    <view class="section">
      <view class="section-title">位置签到</view>
      <button class="operate-btn" @click="doSign" :disabled="isSigning">
        {{ isSigning ? '签到中...' : '立即签到' }}
      </button>

      <view class="sign-result" v-if="signResult">
        <view class="result-item">
          <text class="item-label">签到时间:</text>
          <text class="item-value">{{ signResult.time }}</text>
        </view>
        <view class="result-item">
          <text class="item-label">签到位置:</text>
          <text class="item-value">{{ signResult.address }}</text>
        </view>
        <view class="result-item">
          <text class="item-label">经纬度:</text>
          <text class="item-value">({{ signResult.lat.toFixed(6) }}, {{ signResult.lng.toFixed(6) }})</text>
        </view>
      </view>
    </view>

    <!-- 轨迹追踪区域 -->
    <view class="section">
      <view class="section-title">实时轨迹</view>
      <button class="operate-btn" @click="startTrace" v-if="!isTracing">开始轨迹追踪</button>
      <button class="operate-btn stop-btn" @click="stopTrace" v-if="isTracing">停止轨迹追踪</button>

      <view class="trace-list" v-if="tracePoints.length > 0">
        <view class="trace-title">已记录 {{ tracePoints.length }} 个位置点</view>
        <view class="trace-item" v-for="(point, index) in tracePoints" :key="index">
          <text class="point-index">{{ index + 1 }}. </text>
          <text class="point-coord">({{ point.lat.toFixed(6) }}, {{ point.lng.toFixed(6) }})</text>
        </view>
      </view>
    </view>
  </view>
</template>

<script setup>
import { ref, onUnmounted } from 'vue';
import { NativeUtils } from '@/utils/native-utils.js';
import { showToast, showLoading, hideLoading } from '@dcloudio/uni-app';

// 状态管理
const isSigning = ref(false);
const signResult = ref(null);
const isTracing = ref(false);
const tracePoints = ref([]);
let stopTraceFunc = null; // 停止轨迹的函数

// 页面卸载时停止轨迹追踪
onUnmounted(() => {
  if (isTracing.value) stopTrace();
});

// 执行签到
const doSign = async () => {
  isSigning.value = true;
  try {
    showLoading({ title: '获取位置中...' });
    // 单次定位(高精度)
    const location = await NativeUtils.location.getCurrent({
      provider: 'auto',
      accuracy: 1
    });
    hideLoading();

    // 提交签到信息到后端
    const res = await uni.request({
      url: '/api/user/sign-in',
      method: 'POST',
      data: {
        userId: uni.getStorageSync('userId'),
        lat: location.lat,
        lng: location.lng,
        address: location.address,
        time: location.time
      }
    });

    if (res.data.code === 200) {
      signResult.value = location;
      showToast({ title: '签到成功' });
    } else {
      showToast({ title: res.data.msg || '签到失败', icon: 'none' });
    }
  } catch (err) {
    hideLoading();
    showToast({ title: err.message, icon: 'none' });
  } finally {
    isSigning.value = false;
  }
};

// 开始轨迹追踪
const startTrace = async () => {
  try {
    isTracing.value = true;
    tracePoints.value = [];
    showToast({ title: '轨迹追踪已开始' });

    // 连续定位(每3秒更新一次)
    stopTraceFunc = await NativeUtils.location.watch((point) => {
      tracePoints.value.push(point);
      // 限制轨迹点数量(最多30个)
      if (tracePoints.value.length > 30) {
        tracePoints.value.shift();
      }

      // 模拟上传位置到后端
      uploadTracePoint(point);
    }, 3000);
  } catch (err) {
    isTracing.value = false;
    showToast({ title: err.message, icon: 'none' });
  }
};

// 停止轨迹追踪
const stopTrace = () => {
  if (stopTraceFunc) {
    stopTraceFunc();
    stopTraceFunc = null;
  }
  isTracing.value = false;
  showToast({ title: '轨迹追踪已停止' });
};

// 上传轨迹点到后端
const uploadTracePoint = (point) => {
  uni.request({
    url: '/api/trace/upload',
    method: 'POST',
    data: {
      userId: uni.getStorageSync('userId'),
      lat: point.lat,
      lng: point.lng,
      time: new Date().toLocaleString()
    },
    success: (res) => {
      console.log('轨迹点上传成功:', res.data);
    }
  });
};
</script>

<style scoped>
.container {
  padding: 20rpx;
  background-color: #f5f5f5;
  min-height: 100vh;
}

.section {
  background: #fff;
  padding: 30rpx;
  border-radius: 16rpx;
  margin-bottom: 30rpx;
}

.section-title {
  font-size: 32rpx;
  font-weight: bold;
  color: #333;
  margin-bottom: 30rpx;
  padding-bottom: 16rpx;
  border-bottom: 1px solid #f0f0f0;
}

.operate-btn {
  width: 100%;
  height: 88rpx;
  line-height: 88rpx;
  background-color: #007aff;
  color: #fff;
  font-size: 30rpx;
  border-radius: 12rpx;
}

.operate-btn.stop-btn {
  background-color: #ff3b30;
}

.sign-result {
  margin-top: 30rpx;
  padding: 20rpx;
  background-color: #f9f9f9;
  border-radius: 12rpx;
}

.result-item {
  display: flex;
  flex-wrap: wrap;
  margin-bottom: 16rpx;
  font-size: 26rpx;
}

.item-label {
  color: #666;
  width: 180rpx;
}

.item-value {
  color: #333;
  flex: 1;
  word-break: break-all;
}

.trace-list {
  margin-top: 30rpx;
  padding: 20rpx;
  background-color: #f9f9f9;
  border-radius: 12rpx;
  max-height: 400rpx;
  overflow-y: auto;
}

.trace-title {
  font-size: 26rpx;
  font-weight: bold;
  color: #333;
  margin-bottom: 16rpx;
}

.trace-item {
  font-size: 24rpx;
  color: #666;
  margin-bottom: 8rpx;
}

.point-index {
  color: #007aff;
  font-weight: bold;
}
</style>

关键功能
  • 单次定位用于签到,获取高精度位置和地址信息
  • 连续定位支持轨迹追踪,可配置更新间隔
  • 自动申请定位权限,处理授权失败场景
  • 轨迹点数量限制,避免内存占用过大
避坑指南
  • 室内定位精度较低(100-1000米),可提示用户到室外
  • 连续定位耗电较高,非必要时及时停止
  • Android 12+ 需申请 ACCESS_COARSE_LOCATIONACCESS_FINE_LOCATION 权限

场景6:第三方应用跳转

需求:跳转至微信分享内容、高德地图导航,未安装目标应用时引导用户下载。
业务使用示例(社交分享/地图导航)
<template>
  <view class="container">
    <!-- 微信分享 -->
    <view class="app-card" @click="jumpToWechat">
      <image class="app-icon" src="/static/icons/wechat.png"></image>
      <view class="app-info">
        <view class="app-name">微信分享</view>
        <view class="app-desc">分享内容到微信好友</view>
      </view>
    </view>

    <!-- 高德地图导航 -->
    <view class="app-card" @click="jumpToGaodeMap">
      <image class="app-icon" src="/static/icons/gaode.png"></image>
      <view class="app-info">
        <view class="app-name">高德地图导航</view>
        <view class="app-desc">导航到天安门广场</view>
      </view>
    </view>

    <!-- 未安装提示弹窗 -->
    <uni-popup ref="installPopup" type="center">
      <view class="popup-content">
        <view class="popup-title">{{ appName }}未安装</view>
        <view class="popup-desc">是否前往应用商店下载?</view>
        <view class="popup-btns">
          <button class="cancel-btn" @click="closePopup">取消</button>
          <button class="confirm-btn" @click="gotoAppStore">前往下载</button>
        </view>
      </view>
    </uni-popup>
  </view>
</template>

<script setup>
import { ref } from 'vue';
import { NativeUtils, ThirdAppConfig } from '@/utils/native-utils.js';
import { showToast, openURL } from '@dcloudio/uni-app';
import UniPopup from '@dcloudio/uni-ui/lib/uni-popup/uni-popup.vue';

// 状态管理
const installPopup = ref(null);
const appName = ref('');
let currentApp = null; // 当前要跳转的应用配置

// 跳转微信分享
const jumpToWechat = async () => {
  currentApp = ThirdAppConfig.wechat;
  appName.value = currentApp.name;
  try {
    // 检查微信是否安装
    const isInstalled = await NativeUtils.app.checkInstalled(currentApp);
    if (!isInstalled) {
      installPopup.value.open();
      return;
    }

    // 构造分享参数(微信Scheme协议)
    const shareParams = 'dl/business/?scene=1&title=UniApp分享&desc=这是一篇原生能力教程&link=https://example.com';
    // 打开微信并触发分享
    await NativeUtils.app.openThirdApp({
      ...currentApp,
      params: shareParams
    });
  } catch (err) {
    showToast({ title: err.message, icon: 'none' });
  }
};

// 跳转高德地图导航
const jumpToGaodeMap = async () => {
  currentApp = ThirdAppConfig.gaodeMap;
  appName.value = currentApp.name;
  try {
    // 检查高德地图是否安装
    const isInstalled = await NativeUtils.app.checkInstalled(currentApp);
    if (!isInstalled) {
      installPopup.value.open();
      return;
    }

    // 构造导航参数(高德Scheme协议:目的地天安门)
    const navParams = 'android.intent.action.VIEW amapuri://navi?sourceApplication=UniApp&lat=39.908823&lon=116.397470&dev=0&style=2';
    // 打开高德地图并导航
    await NativeUtils.app.openThirdApp({
      ...currentApp,
      params: navParams
    });
  } catch (err) {
    showToast({ title: err.message, icon: 'none' });
  }
};

// 关闭弹窗
const closePopup = () => {
  installPopup.value.close();
};

// 前往应用商店下载
const gotoAppStore = () => {
  if (!currentApp) return;
  closePopup();

  // Android打开应用商店,iOS打开App Store
  const url = plus.os.name === 'Android'
    ? `market://details?id=${currentApp.packageName}`
    : `itms-apps://itunes.apple.com/cn/app/id${currentApp.appId || ''}`;
  openURL(url);
};
</script>

<style scoped>
.container {
  padding: 20rpx;
  background-color: #f5f5f5;
  min-height: 100vh;
}

.app-card {
  display: flex;
  align-items: center;
  background: #fff;
  padding: 24rpx;
  border-radius: 16rpx;
  margin-bottom: 24rpx;
  box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
}

.app-icon {
  width: 80rpx;
  height: 80rpx;
  border-radius: 16rpx;
  margin-right: 24rpx;
}

.app-info {
  flex: 1;
}

.app-name {
  font-size: 30rpx;
  font-weight: bold;
  color: #333;
  margin-bottom: 8rpx;
}

.app-desc {
  font-size: 24rpx;
  color: #666;
}

/* 弹窗样式 */
.popup-content {
  background: #fff;
  border-radius: 20rpx;
  padding: 40rpx;
  width: 600rpx;
}

.popup-title {
  font-size: 32rpx;
  font-weight: bold;
  color: #333;
  text-align: center;
  margin-bottom: 20rpx;
}

.popup-desc {
  font-size: 28rpx;
  color: #666;
  text-align: center;
  margin-bottom: 40rpx;
}

.popup-btns {
  display: flex;
  gap: 20rpx;
}

.cancel-btn, .confirm-btn {
  flex: 1;
  height: 80rpx;
  line-height: 80rpx;
  font-size: 28rpx;
  border-radius: 12rpx;
}

.cancel-btn {
  background-color: #f5f5f5;
  color: #333;
}

.confirm-btn {
  background-color: #007aff;
  color: #fff;
}
</style>

关键功能
  • 统一管理第三方应用配置(包名/Scheme),避免硬编码
  • 跳转前检查应用是否安装,未安装则引导下载
  • 针对 iOS/Android 差异自动处理跳转参数
  • 支持分享、导航等场景的参数传递
避坑指南
  • iOS 需在 manifest.json 中配置 Scheme 白名单:
    "app-plus": {
      "ios": {
        "urlschemewhitelist": ["weixin", "amapuri"],
        "plistcmds": [
          "Add :LSApplicationQueriesSchemes (\"weixin\",\"amapuri\")"
        ]
      }
    }
    
  • 各应用的 Scheme 协议格式需参考官方文档(如微信、高德地图的参数规范)

场景7:应用版本更新

需求:启动时检查新版本,有更新时显示更新提示,支持强制更新(不更新无法使用)和普通更新(可忽略)。
业务使用示例(自动检测/强制更新)
<template>
  <view class="container">
    <view class="current-version">
      当前版本:v{{ currentVersion }} ({{ currentVersionCode }})
    </view>
    
    <button class="check-btn" @click="checkUpdate" :disabled="isChecking">
      {{ isChecking ? '检查中...' : '检查更新' }}
    </button>

    <!-- 更新提示弹窗 -->
    <uni-popup ref="updatePopup" type="center" :mask-click="!updateInfo.force">
      <view class="update-popup">
        <view class="popup-header">
          <view class="update-title">发现新版本 v{{ updateInfo.versionName }}</view>
          <view class="update-subtitle" v-if="updateInfo.force">
            ⚠️ 本次为强制更新,不更新将无法使用
          </view>
        </view>
        
        <view class="update-content">
          <view class="content-title">更新内容:</view>
          <view class="content-desc">{{ updateInfo.content }}</view>
        </view>
        
        <view class="popup-btns">
          <button 
            class="cancel-btn" 
            @click="closeUpdatePopup"
            v-if="!updateInfo.force"
          >
            稍后更新
          </button>
          <button class="confirm-btn" @click="downloadUpdate">
            {{ downloadProgress > 0 ? `下载中(${downloadProgress}%)` : '立即更新' }}
          </button>
        </view>
      </view>
    </uni-popup>
  </view>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { NativeUtils } from '@/utils/native-utils.js';
import { showToast, showLoading, hideLoading } from '@dcloudio/uni-app';
import UniPopup from '@dcloudio/uni-ui/lib/uni-popup/uni-popup.vue';

// 状态管理
const isChecking = ref(false);
const currentVersion = ref('');
const currentVersionCode = ref(0);
const updateInfo = ref({
  hasUpdate: false,
  versionName: '',
  versionCode: 0,
  content: '',
  url: '',
  force: false
});
const updatePopup = ref(null);
const downloadProgress = ref(0);

// 页面加载时获取当前版本信息
onMounted(async () => {
  await NativeUtils.waitReady();
  const versionInfo = NativeUtils.app.getVersionInfo();
  currentVersion.value = versionInfo.versionName;
  currentVersionCode.value = versionInfo.versionCode;
  
  // 自动检查更新
  checkUpdate();
});

// 检查更新
const checkUpdate = async () => {
  isChecking.value = true;
  try {
    // 实际项目中替换为真实的版本检查接口
    const checkUrl = 'https://api.example.com/app/version';
    const result = await NativeUtils.app.checkUpdate(checkUrl);
    
    if (result.hasUpdate) {
      updateInfo.value = { ...result };
      updatePopup.value.open(); // 显示更新弹窗
    } else {
      showToast({ title: '当前已是最新版本' });
    }
  } catch (err) {
    showToast({ title: `检查更新失败:${err.message}`, icon: 'none' });
  } finally {
    isChecking.value = false;
  }
};

// 关闭更新弹窗
const closeUpdatePopup = () => {
  updatePopup.value.close();
};

// 下载更新包并安装
const downloadUpdate = async () => {
  if (downloadProgress.value > 0 && downloadProgress.value < 100) return;
  
  try {
    showLoading({ title: '准备下载...' });
    const downloadDir = NativeUtils.file.getAppDir('downloads');
    const timestamp = new Date().getTime();
    const savePath = `${downloadDir}update-${timestamp}.apk`; // Android为APK
    
    downloadProgress.value = 0;
    hideLoading();
    
    // 下载更新包
    const localPath = await NativeUtils.file.downloadFile(
      updateInfo.value.url,
      savePath,
      (progress) => {
        downloadProgress.value = progress;
      }
    );
    
    // 下载完成,安装更新(仅Android)
    if (plus.os.name === 'Android') {
      showLoading({ title: '安装中...' });
      await NativeUtils.app.installUpdate(localPath);
      hideLoading();
      // 安装完成后会自动重启应用
    } else {
      // iOS需跳转到App Store
      showToast({ title: '请前往App Store更新', icon: 'none' });
      openURL(updateInfo.value.url);
      downloadProgress.value = 0;
    }
  } catch (err) {
    hideLoading();
    showToast({ title: err.message, icon: 'none' });
    downloadProgress.value = 0;
  }
};
</script>

<style scoped>
.container {
  padding: 40rpx;
  background-color: #f5f5f5;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.current-version {
  font-size: 30rpx;
  color: #333;
  margin-bottom: 40rpx;
}

.check-btn {
  width: 300rpx;
  height: 88rpx;
  line-height: 88rpx;
  background-color: #007aff;
  color: #fff;
  font-size: 30rpx;
  border-radius: 12rpx;
}

.update-popup {
  background: #fff;
  border-radius: 20rpx;
  width: 650rpx;
  overflow: hidden;
}

.popup-header {
  padding: 30rpx;
  border-bottom: 1px solid #f0f0f0;
}

.update-title {
  font-size: 32rpx;
  font-weight: bold;
  color: #333;
  text-align: center;
}

.update-subtitle {
  font-size: 26rpx;
  color: #ff3b30;
  text-align: center;
  margin-top: 10rpx;
}

.update-content {
  padding: 30rpx;
  max-height: 400rpx;
  overflow-y: auto;
}

.content-title {
  font-size: 28rpx;
  font-weight: bold;
  color: #333;
  margin-bottom: 16rpx;
}

.content-desc {
  font-size: 26rpx;
  color: #666;
  line-height: 1.6;
  white-space: pre-wrap;
}

.popup-btns {
  display: flex;
  border-top: 1px solid #f0f0f0;
}

.cancel-btn, .confirm-btn {
  flex: 1;
  height: 90rpx;
  line-height: 90rpx;
  font-size: 30rpx;
}

.cancel-btn {
  background-color: #f5f5f5;
  color: #333;
  border-right: 1px solid #f0f0f0;
}

.confirm-btn {
  background-color: #007aff;
  color: #fff;
}
</style>

关键功能
  • 自动获取当前应用版本信息
  • 支持强制更新(弹窗不可关闭)和普通更新
  • 下载过程显示进度,提升用户体验
  • 区分 Android(直接安装APK)和 iOS(跳转App Store)
避坑指南
  • Android 安装APK需在 manifest.json 中添加权限:
    "app-plus": {
      "android": {
        "permissions": ["android.permission.REQUEST_INSTALL_PACKAGES"]
      }
    }
    
  • 强制更新时需确保后端接口配合,拒绝旧版本请求
  • 大版本更新包建议分渠道下载(如区分WiFi/移动网络)

四、WebView嵌套H5调用原生方法

在实际开发中,我们经常需要在UniApp中嵌套H5页面,同时需要让H5能够调用原生设备能力。这种混合开发模式可以充分利用Web技术的灵活性和原生能力的强大功能。下面我们将实现一个完整的示例,展示H5如何与UniApp原生层通信并调用设备能力。

实现原理

WebView嵌套H5调用原生方法的核心原理是:

  • H5通过window.parent.postMessage向UniApp发送消息
  • UniApp监听web-view的message事件接收H5消息
  • UniApp调用原生能力后,通过evalJS方法将结果返回给H5
  • H5监听message事件接收UniApp返回的结果
步骤1:创建H5页面

首先创建一个H5页面,该页面将包含调用原生方法的按钮和显示结果的区域。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>H5调用原生能力示例</title>
    <style>
        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
            padding: 20px;
            background-color: #f5f5f5;
        }
        
        .container {
            max-width: 600px;
            margin: 0 auto;
        }
        
        .title {
            text-align: center;
            color: #333;
            margin-bottom: 30px;
        }
        
        .btn-group {
            display: flex;
            flex-direction: column;
            gap: 15px;
            margin-bottom: 30px;
        }
        
        .native-btn {
            padding: 12px 20px;
            background-color: #007aff;
            color: white;
            border: none;
            border-radius: 8px;
            font-size: 16px;
            cursor: pointer;
            text-align: center;
        }
        
        .native-btn:hover {
            background-color: #0066cc;
        }
        
        .result-area {
            background-color: white;
            border-radius: 8px;
            padding: 20px;
            min-height: 150px;
        }
        
        .result-title {
            font-weight: bold;
            margin-bottom: 10px;
            color: #333;
        }
        
        .result-content {
            color: #666;
            white-space: pre-wrap;
            font-family: monospace;
            font-size: 14px;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1 class="title">H5调用原生能力示例</h1>
        
        <div class="btn-group">
            <div class="native-btn" onclick="callGetDeviceInfo()">获取设备信息</div>
            <div class="native-btn" onclick="callGetLocation()">获取当前位置</div>
            <div class="native-btn" onclick="callShare()">调用微信分享</div>
        </div>
        
        <div class="result-area">
            <div class="result-title">结果:</div>
            <div class="result-content" id="resultContent">等待调用...</div>
        </div>
    </div>

    <script>
        // 向UniApp发送消息的函数
        function sendToUniApp(action, params = {}) {
            const message = {
                type: 'H5_TO_UNI',
                action: action,
                params: params,
                timestamp: new Date().getTime()
            };
            
            // 发送消息到UniApp
            if (window.parent && window.parent.postMessage) {
                window.parent.postMessage(JSON.stringify(message), '*');
                updateResult(`已发送请求: ${action}`);
            } else {
                updateResult('发送失败:不支持postMessage');
            }
        }
        
        // 更新结果显示
        function updateResult(content) {
            const resultElement = document.getElementById('resultContent');
            resultElement.innerHTML = content;
        }
        
        // 调用获取设备信息
        function callGetDeviceInfo() {
            sendToUniApp('getDeviceInfo');
        }
        
        // 调用获取位置信息
        function callGetLocation() {
            sendToUniApp('getLocation');
        }
        
        // 调用微信分享
        function callShare() {
            sendToUniApp('share', {
                title: 'UniApp混合开发示例',
                content: '这是一个H5调用原生分享的示例',
                url: 'https://example.com'
            });
        }
        
        // 监听UniApp返回的消息
        window.addEventListener('message', function(event) {
            try {
                const message = JSON.parse(event.data);
                if (message.type === 'UNI_TO_H5') {
                    updateResult(JSON.stringify(message.data, null, 2));
                }
            } catch (e) {
                console.error('解析消息失败:', e);
            }
        }, false);
    </script>
</body>
</html>

步骤2:创建UniApp页面加载H5并处理通信

接下来创建一个UniApp页面,使用web-view组件加载上面的H5页面,并实现与H5的通信逻辑。

<template>
<view class="container">
 <!-- 导航栏 -->
 <view class="navbar">
   <view class="back-btn" @click="navigateBack">返回</view>
   <view class="nav-title">H5调用原生示例</view>
 </view>
 
 <!-- WebView组件 -->
 <web-view 
   ref="webview"
   :src="h5Url"
   @message="handleH5Message"
   class="webview"
 ></web-view>
 
 <!-- 加载提示 -->
 <uni-loading-page 
   v-if="isLoading" 
   color="#007aff"
 ></uni-loading-page>
</view>
</template>

<script setup>
import { ref, onReady, onLoad } from 'vue';
import { NativeUtils, ThirdAppConfig } from '@/utils/native-utils.js';
import { showToast, navigateBack } from '@dcloudio/uni-app';
import UniLoadingPage from '@dcloudio/uni-ui/lib/uni-loading-page/uni-loading-page.vue';

// 状态管理
const webview = ref(null);
const h5Url = ref('');
const isLoading = ref(true);

// 页面加载时初始化
onLoad(() => {
// 设置H5页面路径(本地H5页面)
h5Url.value = '/hybrid/html/native-call.html';
});

// WebView就绪后获取引用
onReady(() => {
// #ifdef APP-PLUS
webview.value = plus.webview.currentWebview().children()[0];
// #endif

// 隐藏加载提示
setTimeout(() => {
 isLoading.value = false;
}, 1000);
});

// 处理H5发送的消息
const handleH5Message = async (event) => {
try {
 const message = event.detail.data[0];
 console.log('收到H5消息:', message);
 
 // 根据action处理不同的请求
 let result;
 switch (message.action) {
   case 'getDeviceInfo':
     result = await handleGetDeviceInfo();
     break;
   case 'getLocation':
     result = await handleGetLocation();
     break;
   case 'share':
     result = await handleShare(message.params);
     break;
   default:
     result = { code: -1, message: '未知的操作' };
 }
 
 // 将结果返回给H5
 sendToH5({
   action: message.action,
   code: 0,
   data: result,
   message: '操作成功'
 });
} catch (error) {
 console.error('处理H5消息出错:', error);
 // 返回错误信息给H5
 sendToH5({
   action: event.detail.data[0].action,
   code: -1,
   data: null,
   message: error.message || '操作失败'
 });
}
};

// 向H5发送消息
const sendToH5 = (data) => {
if (!webview.value) {
 console.error('webview引用不存在');
 return;
}

// 构造返回消息
const message = {
 type: 'UNI_TO_H5',
 data: data
};

// 将消息发送给H5
const jsCode = `window.postMessage(${JSON.stringify(message)}, '*')`;

// #ifdef APP-PLUS
webview.value.evalJS(jsCode);
// #endif

// #ifdef H5
// 在H5环境下的处理(仅用于调试)
console.log('发送给H5的消息:', message);
// #endif
};

// 处理获取设备信息请求
const handleGetDeviceInfo = async () => {
await NativeUtils.waitReady();
const baseInfo = NativeUtils.device.getBaseInfo();
const uniqueId = await NativeUtils.device.getUniqueId();

return {
 ...baseInfo,
 uniqueId: uniqueId
};
};

// 处理获取位置请求
const handleGetLocation = async () => {
return await NativeUtils.location.getCurrent({
 provider: 'auto',
 accuracy: 1
});
};

// 处理分享请求
const handleShare = async (params) => {
// 检查微信是否安装
const isInstalled = await NativeUtils.app.checkInstalled(ThirdAppConfig.wechat);
if (!isInstalled) {
 throw new Error('微信未安装,无法分享');
}

// 构造分享参数(微信Scheme协议)
const shareParams = `dl/business/?scene=1&title=${encodeURIComponent(params.title)}&desc=${encodeURIComponent(params.content)}&link=${encodeURIComponent(params.url)}`;

// 打开微信并触发分享
await NativeUtils.app.openThirdApp({
 ...ThirdAppConfig.wechat,
 params: shareParams
});

return {
 success: true,
 message: '已打开微信分享'
};
};
</script>

<style scoped>
.container {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
}

.navbar {
height: 44px;
background-color: #007aff;
color: white;
display: flex;
align-items: center;
padding: 0 16px;
position: relative;
z-index: 10;
}

.back-btn {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
}

.nav-title {
flex: 1;
text-align: center;
font-size: 17px;
font-weight: 500;
}

.webview {
flex: 1;
width: 100%;
}
</style>

关键功能解析
  1. 双向通信机制

    • H5通过window.parent.postMessage发送消息到UniApp
    • UniApp通过web-view的@message事件接收消息
    • UniApp通过evalJS方法执行H5中的window.postMessage将结果返回
    • H5通过window.addEventListener('message')接收返回结果
  2. 安全校验

    • 消息中包含type字段区分消息方向(H5_TO_UNI/UNI_TO_H5
    • 包含timestamp防止重复处理
    • 实际项目中可添加签名验证确保消息来源可靠
  3. 能力封装

    • 统一的消息处理中心handleH5Message
    • action分发到不同处理函数
    • 统一的错误处理和结果返回机制
避坑指南
  1. 路径问题

    • 本地H5页面建议放在hybrid/html目录下,使用相对路径访问
    • 网络H5页面需要确保域名在manifest.jsonwebview白名单中
  2. 跨域问题

    • H5页面和UniApp通信时,postMessagetargetOrigin参数建议指定具体域名而非*
    • manifest.json中配置允许的域名:
      "app-plus": {
        "webview": {
          "domain": ["https://your-domain.com"]
        }
      }
      
  3. 时机问题

    • 确保webview组件初始化完成后再调用evalJS方法
    • 可通过setTimeout或监听webview的loaded事件确保时机正确
  4. 参数类型

    • 传递的消息必须是字符串,复杂对象需要使用JSON.stringify序列化
    • 注意特殊字符的编码和解码(如URL、中文等)

通过这种方式,我们可以在保留H5灵活性的同时,充分利用原生设备能力,实现更丰富的功能体验。这种混合开发模式特别适合需要快速迭代的页面和需要原生能力的场景结合的业务需求。

五、开发最佳实践总结

  1. 工具类复用:所有原生能力通过 native-utils.js 调用,避免重复编码,降低维护成本
  2. 权限管理:敏感权限在“即将使用时申请”,而非启动时批量申请,提升用户授权率
  3. 资源清理:监听类接口(网络、定位、蓝牙)必须在 onUnmounted 中取消,避免内存泄漏
  4. 错误处理:所有异步操作用 try/catch 包裹,配合 showToast 给出友好提示
  5. 跨平台兼容:工具类已处理 iOS/Android 差异,业务层无需关心底层实现细节
  6. 用户体验:耗时操作(下载、定位)显示加载状态,进度类操作显示进度条

通过本文的统一工具类和7大核心场景实现,你可以系统化解决 UniApp 混合开发中的原生能力调用问题。所有代码可直接复制到项目中,根据实际需求调整接口地址和参数即可快速落地。工具类的模块化设计也便于后续扩展其他原生能力(如NFC、推送等)。