2025-06-10

一、引言

随着前端技术快速发展,需求往往需要在多端(原生 Android/iOS、H5 网站、微信/支付宝/百度小程序等)同时发布,而维护多套代码成本极高。uniapp 作为 DCloud 出品的跨端框架,能够用一套 Vue 风格的源码,通过 HBuilderX 或 CLI,一次编写、编译到多个运行环境,大幅度提高开发效率。本文将从项目搭建→编码调试→平台特性→打包构建→发布上线等环节,逐步讲解如何完成一个跨 iOS、Android、Web 与各类小程序的完整 uniapp 项目。

全篇内容包括:

  1. 项目环境准备与基础搭建
  2. uniapp 项目结构与核心配置解析
  3. 页面示例与跨端差异处理(#ifdef 条件编译)
  4. H5(Web)端开发与发布
  5. 微信/支付宝/百度小程序端开发与发布
  6. 原生 App(iOS/Android)端开发与发布
  7. 多端资源管理与性能优化
  8. 完整流程图解与常见问题

通过示例代码和 ASCII 图解,你将对 uniapp 的跨多端原理与实操流程有全面而深入的了解,能够在项目中快速上手并发布到各个平台。


二、项目环境准备与基础搭建

2.1 环境依赖

  1. HBuilderX 或 CLI

    • 推荐使用最新版本的 HBuilderX(≥ v3.0),它集成了 uniapp 可视化项目创建、编译、真机调试、打包发布等功能。
    • 如果偏好命令行,也可使用 Vue CLI + @dcloudio/vue-cli-plugin-uni 搭建。本文以 HBuilderX 为主,另附 CLI 方式要点。
  2. Node.js & Git

    • 安装 Node.js(≥ v10),用于部分插件与脚本。
    • 安装 Git,方便版本控制和模板初始化。
  3. 目标平台开发环境

    • 微信小程序:微信开发者工具。
    • 支付宝小程序:支付宝小程序开发者工具。
    • 百度小程序:百度小程序开发者工具。
    • iOS:macOS + Xcode(用于打包 IPA)。
    • Android:Android Studio(用于打包 APK)。
    • H5:任意支持 HTTPS 的 Web 服务器(可用本地 npm run serve 或使用 Nginx、GitHub Pages 等发布)。

2.2 创建 uniapp 项目

2.2.1 HBuilderX 可视化创建

  1. 打开 HBuilderX,选择 “文件→新建→项目→uni-app”,填写项目名称(如 uni-multi-platform-demo),选择空白模板
  2. 创建后,会得到一个包含 pages.jsonmanifest.jsonApp.vuemain.js 等文件的目录结构(见下节详解)。
  3. 在 HBuilderX 左侧选中项目根目录,点击工具栏**“运行”**按钮,可以选择“运行到浏览器-Chrome”查看 H5,也可“运行到小程序-微信”预览微信小程序效果。

2.2.2 CLI 创建(可选)

# 全局安装 @vue/cli(如未安装)
npm install -g @vue/cli

# 创建uniapp项目
vue create -p dcloudio/uni-preset-vue uni-multi-platform-demo

# 进入项目
cd uni-multi-platform-demo

# 运行 H5(本地预览)
npm run dev:%PLATFORM%  # 例如 npm run dev:h5

# 生成各端代码
npm run build:%PLATFORM%  # 如 build:mp-weixin、build:app-plus 等
:CLI 方式仅需在 package.json 中配置好脚本,使用 npm run dev:h5npm run build:mp-weixin 即可。本文示例主要基于 HBuilderX,CLI 方式可参考官方文档。

三、uniapp 项目结构与核心配置解析

创建完成后,项目结构大致如下(以 HBuilderX 默认空白模板为例):

uni-multi-platform-demo/
├─ components/          # 可存放自定义组件
├─ pages/               # 页面目录(每个子文件夹为一个页面)
│   ├─ index/
│   │    ├─ index.vue
│   │    └─ index.json  # 页面配置(部分情况下需要)
│   └─ about/
│        ├─ about.vue
│        └─ about.json
├─ static/              # 静态资源:图片、字体、视频等
│   └─ logo.png
├─ unpackage/           # 编译后生成的各端临时文件。不要在此目录下修改源代码!
├─ App.vue              # 全局 Vue 根组件
├─ main.js              # 入口 JS(初始化小程序/APP)
├─ pages.json           # 页面路由 & 导航栏 & 组件等全局配置
├─ manifest.json        # 应用发布打包配置(APP 端配置)
└─ manifest.*.json      # 若使用多渠包,可有多个 platform 相应配置
└─ pays.drawjson        # 云打包平台等相关配置(可忽略)

3.1 pages.json 详解

pages.json 是 uniapp 的路由 & 页面配置总入口,它决定了最终项目的页面路径导航栏标题分享设置底部 TabBar 等。典型示例:

// pages.json
{
  "pages": [
    {
      "path": "pages/index/index",
      "style": {
        "navigationBarTitleText": "首页"
      }
    },
    {
      "path": "pages/about/about",
      "style": {
        "navigationBarTitleText": "关于"
      }
    }
  ],
  "globalStyle": {
    "navigationBarTextStyle": "black",
    "navigationBarTitleText": "uni-app",
    "navigationBarBackgroundColor": "#FFFFFF",
    "backgroundColor": "#F2F3F5"
  },
  "tabBar": {
    "color": "#7A7E83",
    "selectedColor": "#007AFF",
    "backgroundColor": "#ffffff",
    "borderStyle": "black",
    "list": [
      {
        "pagePath": "pages/index/index",
        "iconPath": "static/icons/home.png",
        "selectedIconPath": "static/icons/home-selected.png",
        "text": "首页"
      },
      {
        "pagePath": "pages/about/about",
        "iconPath": "static/icons/about.png",
        "selectedIconPath": "static/icons/about-selected.png",
        "text": "关于"
      }
    ]
  }
}
  • pages:页面数组,顺序决定小程序/APP 端页面栈的顺序与路由顺序;每个对象 path 对应某个页面文件夹(如 pages/index/index.vue)。
  • globalStyle:定义全局导航栏与背景色等属性,可覆盖各端原生默认样式。
  • tabBar:若需要底部 Tab 栏,则在此配置图标、文字与对应 pagePath。在 H5 端会渲染为自定义 Tab,而在小程序/APP 端会渲染原生 Tab(或仿 Tab)。
注意:小程序端页面的路径不能超过 10 层;路径中不要出现大小写冲突,否则会导致编译错误或真机奔溃。

3.2 manifest.json 与原生打包配置

manifest.json 是针对 APP(iOS/Android)打包的配置文件,主要包含应用名称、AppID、版本号、图标、权限设置、SDK 集成等信息。HBuilderX 可视化界面会自动同步修改此文件。示例(精简版):

// manifest.json
{
  "name": "uni-multi-platform-demo",
  "appid": "__UNI__XXXXXXXX",
  "versionName": "1.0.0",
  "versionCode": "100",
  "description": "一个 uniapp 跨多端示例项目",
  "h5": {
    "title": "uniapp 多端示例",
    "routerMode": "hash",
    "devServerPort": 8080,
    "favicon": "static/logo.png"
  },
  "app-plus": {
    "distribute": {
      "android": {
        "package": "com.example.unimultiplatform",
        "keystorePath": "build/keystore/your.keystore",
        "keystorePassword": "your_keystore_password",
        "alias": "your_alias",
        "aliasPassword": "your_alias_password"
      },
      "ios": {
        "codeSign": {
          "developmentTeam": "YOUR_TEAM_ID",
          "provisioningProfile": "build/provisioning/your_mobileprovision",
          "codeSignIdentity": "iPhone Distribution: Your Company (TEAMID)"
        }
      }
    },
    "sdkConfigs": {
      "WXSDK": {
        "appId": "wxxxxxxxxxxxxxxxx",
        "universalLink": "https://xxxxxx.com/"
      }
    }
  }
}
  • name / appid:APP 应用名称与 HBuilderX 分配的 uni-app AppID;
  • versionName / versionCode:iOS/Android 端的版本号与版本代码;
  • h5:H5 端的标题、routerModehashhistory)以及开发服务器端口等;
  • app-plus.distribute.android:Android 打包参数,包括包名(package)、签名文件路径与密码等;
  • app-plus.distribute.ios:iOS 打包参数,包括开发团队 ID、描述文件(.mobileprovision)以及签名证书标识;
  • app-plus.sdkConfigs:可配置集成第三方 SDK(如微信登录、统计、推送等),上例演示了微信原生 SDK 的 appIduniversalLink

注意:

  • Android 端打包时,keystore 文件需自行在本地生成并配置正确路径;
  • iOS 端打包需在 macOS 上使用 Xcode 证书管理工具,获取 DevelopmentTeamProvisioning ProfileCodeSignIdentity
  • H5 端通过 manifest.json 配置的 h5.routerMode 影响页面路径的 URL 形式(hash 推荐跨域兼容性更好);
  • 各平台的 manifest.json 节点名以 app-plus 开头,HBuilderX 打包时会读取并生成对应平台项目文件。

四、页面示例与跨端差异处理

4.1 简单页面示例:pages/index/index.vue

下面给出一个包含入口按钮、分享按钮与跳转示例的页面,演示跨端差异处理:

<template>
  <view class="page-container">
    <text class="title">uniapp 多端开发示例</text>

    <button @click="goToAbout">跳转到关于页</button>

    <!-- 跨端分享按钮 -->
    <button @click="onShareButton">统一分享</button>

    <!-- 仅在 APP 端显示 -->
    <button v-if="platform === 'app-plus'" @click="onAppOnlyAction">
      仅 APP 端执行
    </button>

    <!-- 仅在小程序端显示 -->
    <button v-if="isMp" open-type="share">分享到小程序</button>

    <!-- 仅在 H5/公众号端显示 -->
    <button v-if="platform.startsWith('h5')" @click="onWebOnlyAction">
      仅 H5 端执行
    </button>
  </view>
</template>

<script>
// 引入平台检测与分享工具
import { getPlatform } from '@/utils/platform';
import { shareHandler } from '@/utils/share';

export default {
  data() {
    return {
      platform: getPlatform()
    };
  },
  computed: {
    isMp() {
      return this.platform.startsWith('mp-');
    }
  },
  methods: {
    goToAbout() {
      uni.navigateTo({ url: '/pages/about/about' });
    },
    onShareButton() {
      const shareConfig = {
        title: 'uniapp 跨多端示例',
        desc: '覆盖 iOS、Android、Web、小程序 全端',
        link: 'https://example.com/h5/share.html',
        imgUrl: 'https://example.com/static/thumb.png',
        path: '/pages/index/index?from=mini',
        miniProgram: {
          id: 'gh_abcdefg',
          path: '/pages/index/index',
          type: 0
        }
      };
      shareHandler(shareConfig);
    },
    onAppOnlyAction() {
      uni.showToast({ title: '仅在 APP 端执行', icon: 'none' });
    },
    onWebOnlyAction() {
      alert('仅在 H5 端执行');
    }
  }
};
</script>

<style scoped>
.page-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 50px;
}
.title {
  font-size: 24px;
  margin-bottom: 30px;
}
button {
  margin: 10px 0;
  padding: 10px 20px;
  font-size: 16px;
}
</style>

说明:

  • getPlatform() 返回当前端标识,通过 v-if 条件渲染让某些按钮只在特定端显示;
  • 小程序端分享按钮需使用 open-type="share" 才能触发 onShareAppMessage
  • H5 端 onWebOnlyAction 演示 alert 弹窗;APP 端 onAppOnlyAction 演示 uni.showToast
  • “统一分享”按钮调用封装好的 shareHandler(),不同端会执行不同分享逻辑。

4.2 条件编译示例(#ifdef / #ifndef

在 uniapp 中,可以使用如下条件编译指令进行更细粒度的端内分支:

<template>
  <view>
    <!-- 仅在 APP 端显示 -->
    <!-- #ifdef APP-PLUS -->
    <text>仅 APP 端可见</text>
    <!-- #endif -->

    <!-- 仅在微信小程序端显示 -->
    <!-- #ifdef MP-WEIXIN -->
    <text>仅微信小程序端可见</text>
    <!-- #endif -->

    <!-- 仅在 H5 端显示 -->
    <!-- #ifdef H5 -->
    <text>仅 H5 端可见</text>
    <!-- #endif -->

    <!-- 仅在非 APP 端显示 -->
    <!-- #ifndef APP-PLUS -->
    <text>非 APP 端可见</text>
    <!-- #endif -->
  </view>
</template>

使用条件编译可以将不需要打包到某端的代码块彻底剔除,减少包体积。例如,将“仅 H5 端”的依赖放在 #ifdef H5 中,在小程序/APP 打包时不会包含这些代码。


五、H5(Web)端开发与发布

5.1 H5 端路由与打包

uniapp H5 端生成的是一套纯前端网页,页面路由默认采用hash 模式(在 manifest.json 中可切换为 history)。编译后会在项目根目录生成 unpackage/dist/build/h5/ 文件夹,其中包含 index.htmlstaticfavicon.ico 等文件。

5.1.1 H5 本地预览

在 HBuilderX 中选择“运行→运行到浏览器-Chrome”,即可自动启动本地 localhost 服务(默认端口 8080)预览 H5 端效果;也可以在命令行中执行:

npm run dev:h5

然后在浏览器访问 http://localhost:8080/#/pages/index/index 即可看到效果。

5.1.2 H5 打包上线

  1. 在 HBuilderX 左侧项目根目录,点击“发行→网站-H5→发行”或在命令行执行 npm run build:h5
  2. 打包完成后,生成的 dist/build/h5/ 目录下的文件即为可部署静态资源;
  3. dist/build/h5/* 上传到任意支持 HTTPS 的服务器(如 Nginx、Apache、GitHub Pages、Netlify、Vercel 等),即可通过域名访问。
  4. 若你在 manifest.json 中设置了 routerMode: 'history',则需在服务器端做404 回退index.html,以便前端路由正常工作;若使用 hash,则无需额外配置。

ASCII 图解:H5 部署流程

+---------------------------------------+
|   uniapp 项目根目录                    |
| ┌───────────────────────────────────┐ |
| │ 运行:npm run build:h5             │ |
| │ ─────────────────────────────────  │ |
| │ 生成 dist/build/h5/ 目录            │ |
| └───────────────────────────────────┘ |
|                ↓                       |
|      上传 dist/build/h5/* 到服务器     |
|                ↓                       |
|      域名指向 → 浏览器访问 https://…   |
+---------------------------------------+

5.2 H5 端常见优化

  1. 打包体积:在 vue.config.jsmanifest.json 中关闭 SourceMap、开启压缩、提取公共包:

    // vue.config.js (如果使用 CLI)
    module.exports = {
      productionSourceMap: false,
      configureWebpack: {
        optimization: {
          splitChunks: {
            chunks: 'all'
          }
        }
      }
    };
  2. PWA 与离线缓存:可利用 Workbox 将 H5 端打包为 PWA,支持离线访问和缓存策略,但一般小程序/APP 端已打包,不必过度依赖 PWA。
  3. 环境变量:在 H5 端可通过 process.env.NODE_ENV 判断生产/开发环境,进行不同配置。例如 API 接口地址,C端调用可使用 uni.request

六、小程序端开发与发布(微信/支付宝/百度)

6.1 微信小程序开发与发布

6.1.1 本地预览与调试

  1. 在 HBuilderX 中点击“运行→运行到小程序模拟器-微信”,自动打开微信开发者工具;也可在命令行执行 npm run dev:mp-weixin
  2. 在微信开发者工具里,可以看到 miniprogram_dist/build/mp-weixin/ 目录下的源码,方便进行真机预览与调试。

6.1.2 发布到微信小程序

  1. 小程序账号准备:确保你有一个已注册且已认证的微信小程序账号,并获得 AppID;
  2. HBuilderX 打包:在 HBuilderX 中点击“发行→小程序-微信”,输入 AppID,选择“云打包”或“本地打包”;

    • 本地打包:生成原生小程序项目,路径为 unpackage/dist/build/mp-weixin/,然后在微信开发者工具手动导入该项目并上传;
    • 云打包:填写 AppID、版本号、版本描述后,一键提交给 DCloud 云打包平台,生成可直接提交审核的小程序代码。
  3. 微信开发者工具上传审核:若本地打包,打开“微信开发者工具”,点击“上传”,填写版本号、描述等,提交审核。
  4. 审核通过后发布:在微信公众平台后台,审核通过后可选择发布上线。

ASCII 图解:微信小程序打包流程

uniapp 源码
  ↓ (HBuilderX “发行→小程序-微信”)
unpackage/dist/build/mp-weixin/   (已生成微信小程序项目)
  ↓ (导入到微信开发者工具)
微信开发者工具 → 上传 → 审核 → 发布上线

6.1.3 小程序端注意事项

  • 页面数量限制:微信小程序最多 50 个页面;页面路径不能超过 10 层;
  • 接口限额:注意 uni.request 等网络请求不要滥用,合理缓存或限流;
  • 分享逻辑:需在页面内实现 onShareAppMessage / onShareTimeline
  • 分包与分隔加载:当小程序体积过大时,可在 pages.json 中配置 subPackages,拆分页面分包加载,首包控制在 2MB 以内。

6.2 支付宝小程序开发与发布

6.2.1 本地预览与调试

  1. 在 HBuilderX 中点击“运行→运行到小程序模拟器-支付宝”,自动打开支付宝小程序开发者工具;或命令行执行 npm run dev:mp-alipay
  2. unpackage/dist/build/mp-alipay/ 下的目录即为支付宝小程序源代码,可在工具中预览与调试。

6.2.2 发布到支付宝小程序

  1. 账号准备:拥有支付宝小程序账号与 AppID;
  2. HBuilderX 打包:点击“发行→小程序-支付宝”,输入 AppID,选择“本地打包”或“云打包”;
  3. 支付宝开发者工具上传:若本地打包,将 unpackage/dist/build/mp-alipay/ 导入工具,填写版本信息后上传;
  4. 审核与上线:在支付宝小程序管理后台提交审核,审核通过后即可发布。
提示:支付宝小程序对代码量要求严格,最终包体大小应控制在 2MB 左右,若超限需开启“分包”。

6.3 百度小程序开发与发布

6.3.1 本地预览与调试

  1. 在 HBuilderX 中点击“运行→运行到小程序模拟器-百度”或命令行执行 npm run dev:mp-baidu
  2. unpackage/dist/build/mp-baidu/ 下文件即为百度小程序项目,可在百度开发者工具中预览。

6.3.2 发布到百度小程序

  1. 账号准备:拥有百度智能小程序账号与 AppID;
  2. HBuilderX 打包:点击“发行→小程序-百度”,输入 AppID,选择“本地打包”或“云打包”;
  3. 百度开发者工具上传:将生成的项目导入百度开发者工具,填写版本、提交审核;
  4. 审核与上线:审核通过后发布。
注意:百度小程序和微信小程序类似,也有页面数量与体积限制,需分包分离。

七、原生 App(iOS/Android)端开发与发布

7.1 APP 端流程图解

uniapp 源码
   ↓ (HBuilderX “发行→原生 App-云打包 / 本地打包”)
unpackage/dist/build/app/  (iOS Xcode 项目 或 Android Gradle 项目)
   ↓ (Xcode / Android Studio 打开项目)
   ↓ (生成 IPA / APK)
   ↓ (上传 App Store / 上架 Google Play / 内部测试)

7.2 Android 端打包与发布

7.2.1 生成签名文件(Keystore)

# 在命令行生成 .keystore,例如:
keytool -genkey -v -keystore yourapp.keystore \
  -alias your_alias \
  -keyalg RSA -keysize 2048 -validity 10000
# 过程中会提示输入 keystore 密码、别名密码、姓名、组织等

将生成的 yourapp.keystore 放到项目中,例如放在 build/keystore/yourapp.keystore,并在 manifest.json 中配置好:

"app-plus": {
  "distribute": {
    "android": {
      "package": "com.example.uniplatformdemo",
      "keystorePath": "build/keystore/yourapp.keystore",
      "keystorePassword": "keystore_password",
      "alias": "your_alias",
      "aliasPassword": "alias_password"
    }
  }
}

7.2.2 本地打包 Android

  1. 在 HBuilderX 中,选择“发行→原生 App-本地打包”,选择 Android 平台;
  2. 填写包名、版本号、签名信息(已在 manifest.json 中配置,可直接勾选);
  3. 点击“打包”,HBuilderX 会生成一个 *.apk 文件(存放在 unpackage/dist/build/app/**/*.apk);
  4. 用真机或模拟器安装测试:

    adb install -r unpackage/dist/build/app/android/xxx.apk
  5. 测试无误后,将 APK 上传到 Google Play、华为应用市场、应用宝等第三方应用商店。

7.2.3 云打包 Android

  1. 在 HBuilderX 中勾选 “云打包”,填写应用名称、版本号、签名信息等;
  2. 提交打包,等待完成后在“云打包”记录中下载 APK;
  3. 测试并上传到各大应用商店。

注意

  • Gradle 构建时可能出现依赖冲突,可在 HBuilderX “项目设置→插件管理”中查看使用的插件版本;
  • 如果需要集成第三方原生 SDK(如推送、统计、地图等),可在项目 components/plugins 中复制对应 .aar / .jar 文件,并修改 Android 工程配置(可参考文档或示例);
  • Android 端需要关注权限声明(在 manifest.json 中配置),例如相机、定位等,打包时会生成原生 AndroidManifest.xml。

7.3 iOS 端打包与发布

7.3.1 准备证书与描述文件

  1. Apple 开发者账号:登录 Apple Developer 网站,创建一个 App ID 并开启所需功能(推送、健康、定位等);
  2. 创建证书:在 “Certificates, IDs & Profiles” 中创建 iOS Development 证书iOS Distribution 证书,并下载到本地;双击安装到 macOS 钥匙串中;
  3. 创建描述文件:分别创建 Development Provisioning Profile(野狗调试) 和 Distribution Provisioning Profile(App Store 上架);将 .mobileprovision 文件下载到本地。

7.3.2 本地打包 iOS

  1. manifest.json 中填入:

    "app-plus": {
      "distribute": {
        "ios": {
          "codeSign": {
            "developmentTeam": "YOUR_TEAM_ID",
            "provisioningProfile": "build/provisioning/your.mobileprovision",
            "codeSignIdentity": "iPhone Distribution: Your Company (YOUR_TEAM_ID)"
          }
        }
      }
    }
    • developmentTeam 为 Apple 开发者账号中的 Team ID;
    • provisioningProfile 填写本地 .mobileprovision 文件路径;
    • codeSignIdentity 与证书名称保持一致。
  2. 在 HBuilderX 中,选择“发行→原生 App-本地打包”,选择 iOS 平台;输入 Bundle Identifier(与 App ID 一致),选择签名证书与描述文件;
  3. 点击“打包”,HBuilderX 会生成一个 .ipa 文件,存放在 unpackage/dist/build/app/ios/ 下;
  4. 使用 Application Loader(或 Xcode → Organizer)上传 .ipa 至 App Store Connect;或使用 TestFlight 发布测试。

7.3.3 云打包 iOS

  1. 在 HBuilderX 中勾选**“云打包”**,填写证书内容(点击导入 .p12 证书、描述文件 .mobileprovision),填写 Bundle ID、版本号、版本描述等;
  2. 提交打包,等待完成后下载 .ipa
  3. 上传到 App Store Connect,或使用第三方分发平台(蒲公英、Fir 等)进行测试分发。

注意

  • iOS 端打包只能在 macOS 上完成;云打包平台代替本地 Xcode 编译;
  • 由于 Apple 政策限制,想要集成第三方原生 iOS SDK,需要在 HBuilderX “发行插件”中配置或借助原生插件;

八、多端资源管理与性能优化

8.1 静态资源(图片、字体、音视频)

  1. 放在 static/ 目录

    • static 下的所有文件会原样复制到打包产物根目录;H5 引用路径为 /static/xxx.png;小程序端引用路径为 /static/xxx.png;APP 端可用 "/static/xxx.png""../../../static/xxx.png"
  2. 按需加载

    • 对于 H5 端可使用 lazy-load、CDN 加速;小程序端可使用 <image lazy-load /> 实现图片懒加载。
  3. 尺寸与压缩

    • 推荐 SVGWebP 格式降低体积;对 PNG/JPG 进行压缩;确保 APP 端 APK/IPA 体积不过大。

8.2 条件编译处理资源

如果某些资源仅在特定端有效,可用条件编译提前剔除。例如:

<template>
  <view>
    <!-- #ifndef H5 -->
    <image src="@/static/native-only.png" />
    <!-- #endif -->

    <!-- #ifdef H5 -->
    <image src="/static/web-only.jpg" />
    <!-- #endif -->
  </view>
</template>

这样,编译到 H5 时会移除 native-only.png 引用,减小包体积;编译到 APP/小程序 时会移除 web-only.jpg

8.3 性能优化技巧

  1. 减少首次渲染体积

    • 在 H5 端,通过 vue.config.js 拆分代码(splitChunks);
    • 在小程序/APP 端,通过分包(小程序端)和按需编译(APP 端)。
  2. 合理使用缓存

    • H5 端可结合 Service Worker 离线缓存;小程序端可使用 uni.setStorage 缓存接口返回数据;APP 端可使用 SQLite 或原生缓存。
  3. 事件与定时器释放

    • 在 uniapp 页面 onUnload 中清理 setIntervaluni.$on 事件监听等,避免内存泄漏。
  4. 图片切片与懒加载

    • 对大型列表使用虚拟列表组件(如 uni-virtual-list);对长图、视频等做懒加载/骨架屏。

九、完整流程图解与常见问题

以下用 ASCII 图解串联起 uniapp 跨多端的整体流程,帮助你理清思路。

┌────────────────────────────────┐
│        1. 项目初始化           │
│  HBuilderX(或 CLI)→ 新建 uniapp 项目 │
└────────────────────────────────┘
                  ↓
┌───────────────────────────────────────────────┐
│        2. 编写业务代码 & 跨端差异处理         │
│  - pages.json 配置页面路由/导航/TabBar         │
│  - manifest.json 配置 APP 端包名/签名/SDK      │
│  - 页面使用 #ifdef 做端内逻辑分支             │
│  - 通过 uni.request、uni.navigateTo 等 API    │
│  - 资源放置在 static 目录,条件编译剔除不必要资源 │
└───────────────────────────────────────────────┘
                  ↓
┌───────────────────────────────────────────────┐
│ 3. 本地预览与调试                              │
│  - H5 端:运行至浏览器(npm run dev:h5)        │
│  - Weixin:运行至微信开发者工具(npm run dev:mp-weixin) │
│  - Alipay:运行至支付宝开发者工具             │
│  - Baidu:运行至百度开发者工具                 │
│  - APP:运行至真机或模拟器(HBuilderX→运行到真机)   │
└───────────────────────────────────────────────┘
                  ↓
┌───────────────────────────────────────────────┐
│ 4. 多端构建                                   │
│  - H5:npm run build:h5 → 生成 dist/build/h5/ │
│  - mp-weixin:npm run build:mp-weixin → 生成微信小程序代码 │
│  - mp-alipay:npm run build:mp-alipay → 支付宝小程序代码 │
│  - mp-baidu:npm run build:mp-baidu → 百度小程序代码      │
│  - app-plus 本地打包 → 生成 Android APK / iOS IPA   │
│  - app-plus 云打包 → 同时生成各平台安装包            │
└───────────────────────────────────────────────┘
                  ↓
┌───────────────────────────────────────────────┐
│ 5. 发布上线                                   │
│  - H5:部署到 HTTPS 服务器(Nginx、Netlify 等)  │
│  - 微信小程序:微信开发者工具上传、审核、发布     │
│  - 支付宝小程序:支付宝开发者工具上传、审核、发布   │
│  - 百度小程序:百度开发者工具上传、审核、发布       │
│  - Android:上传至 Google Play、应用商店等         │
│  - iOS:上传至 App Store Connect → 审核 → 发布    │
└───────────────────────────────────────────────┘
                  ↓
┌───────────────────────────────────────────────┐
│ 6. 版本维护与迭代                              │
│  - 线上 Bug 修复 → 拉取最新分支 → 修改 → 重复上诉流程  │
│  - 持续监控:统计 SDK、Crash 分析、用户反馈         │
│  - 性能优化:打包体积、启动速度、渲染帧率          │
└───────────────────────────────────────────────┘

9.1 常见问题汇总

  1. “页面过多导致小程序包体积过大”

    • 解决:在 pages.json 中配置 分包subPackages),将不常用或体积大的页面放到子包;首包控制在 2MB 以内。
  2. “APP 端打包失败:证书签名错误”

    • 解决:检查 manifest.json 中 iOS/Android 签名配置是否正确,证书与描述文件是否匹配;Android 检查 keystore 路径、密码与别名;iOS 检查 Team ID、mobileprovision 与证书是否一致。
  3. “H5 端分享不生效”

    • 解决:确保 wx.config 中的 URL 与浏览器地址完全一致(包括协议、域名、路径与参数,去掉 hash),并且域名已在微信公众平台-开发配置中添加;确保 jsApiList 中包含相应分享接口;在 wx.ready 回调中再调用 wx.updateAppMessageShareData 等。
  4. “小程序端分享图标不显示”

    • 解决:小程序分享的 imageUrl 必须是远程 HTTPS 链接,不能使用本地 static 目录路径。
  5. “Android 报 Crash:Missing Splash Screen”

    • 解决:检查 manifest.json 中 Android 启动图配置;或在 App.vue 中手动关闭 waiting 启动图:

      onLaunch() {
        // H5 端可忽略
        if (uni.getSystemInfoSync().platform === 'android') {
          plus.navigator.closeSplashscreen();
        }
      }
  6. “条件编译无效,代码仍然打包”

    • 解决:确保使用的是 HBuilderX 打包(#ifdef 只在 HBuilderX 编译时生效);CLI 模式编译需要在 uniapp.config.js 中开启相应插件;不要把 #ifdef 写在同一行注释内。

十、总结

本文全面讲解了如何使用 uniapp 实现一次开发、多端发布的完整流程,涵盖以下要点:

  1. 项目搭建:HBuilderX 或 CLI 快速创建 uniapp 项目;安装 Node.js、Git、各平台开发工具。
  2. 项目结构与配置pages.json 管理路由与样式,manifest.json 管理 APP 签名与 SDK。
  3. 跨端差异处理:使用 getPlatform() + 条件编译指令(#ifdef/#ifndef)区分 APP、H5、小程序代码。
  4. H5 端开发与发布:本地预览 → 打包 → 部署 HTTPS 服务器;微信公众号需配合 JS-SDK 签名。
  5. 小程序端开发与发布:本地预览 → 云打包/本地打包 → 微信/支付宝/百度小程序工具上传 → 审核 → 发布。
  6. 原生 APP 开发与发布:HBuilderX 本地/云打包 → Android APK 签名发布、iOS IPA 签名发布 → App Store/Google Play 审核上架。
  7. 资源管理与优化static/ 放置静态资源,条件编译剔除端内无关资源;使用分包、懒加载、压缩等技巧优化包体与性能。
  8. 统一分享示例:借助封装的 shareHandler() 实现 APP、小程序、H5/公众号多端一键分享;
2025-06-03
导读:在 Flutter 项目中,Android 工程是承载应用运行时的关键部分,负责将 Dart 代码和资源打包为可在 Android 设备上运行的 APK。深入理解其目录结构、Gradle 构建流程、Native 与 Dart 代码集成等,对于性能调优、原生插件开发以及故障排查至关重要。本文将以Flutter Android 工程为核心,从整体结构到编译细节逐层剖析,配以代码示例图解详细说明,帮助你全面掌握 Flutter 应用在 Android 端的“从源码到 APK”全过程。

目录

  1. 项目层级与目录结构总览
  2. 关键配置文件详解

    • 2.1 android/build.gradle(顶层 Gradle 脚本)
    • 2.2 android/app/build.gradle(模块级 Gradle 脚本)
    • 2.3 android/gradle.propertieslocal.properties
    • 2.4 AndroidManifest.xml
  3. Flutter 与 Android 原生的对接

    • 3.1 MainActivity.kt / MainActivity.java:Flutter 引擎启动入口
    • 3.2 io.flutter.embedding.android.FlutterActivity 工作机制
    • 3.3 Flutter Gradle 插件 (FlutterPlugin) 的作用
  4. Gradle 构建流程全解析

    • 4.1 构建命令与任务链:flutter build apk → Gradle Task
    • 4.2 AOT 编译:从 Dart 到 ARM/ASM 的转换
    • 4.3 打包 Asset:如何将 flutter_assets 注入到 APK 中
    • 4.4 原生库链接:armeabi-v7a、arm64-v8a、x86 架构划分
    • 4.5 签名与对齐:signingConfigszipalign
    • 4.6 多渠道打包(Gradle flavor)示例
  5. Java / Kotlin 与 Dart 通信通道

    • 5.1 MethodChannelEventChannelBasicMessageChannel 源码路径
    • 5.2 Android 端插件注册流程(GeneratedPluginRegistrant
    • 5.3 Native 调试:如何在 Android Studio 断点 Dart 调用
  6. 资源、ABI 与包结构细节

    • 6.1 app/src/main/res:Drawable、layout、values 等目录
    • 6.2 lib/flutter_assets/:Asset Catalog 打包原理
    • 6.3 jniLibs/:原生库目录与多架构支持
    • 6.4 APK 内部目录树示意(使用 apktoolaapt dump tree
  7. 调优与常见问题

    • 7.1 构建速度优化:Gradle daemon、并行构建、缓存开启
    • 7.2 减少 APK 体积:--split-per-abi,开启 minifyEnabled 与 R8 混淆
    • 7.3 性能剖析:Systrace、APK Analyzer、Profile Mode
    • 7.4 常见打包错误 & 解决方案
  8. 实战示例:自定义原生插件打包

    • 8.1 插件目录与 pubspec.yaml 配置
    • 8.2 Kotlin 端代码示例与注册
    • 8.3 Gradle 修改:添加依赖、混淆设置
    • 8.4 编译输出验证:查看 Native 库与 Dart Bundle
  9. 总结

一、项目层级与目录结构总览

创建一个 Flutter 项目后,android/ 目录下便是 Android 工程的根。典型目录结构如下(仅列出最重要部分):

my_flutter_app/
├── android/
│   ├── app/
│   │   ├── build.gradle
│   │   ├── src/
│   │   │   ├── main/
│   │   │   │   ├── AndroidManifest.xml
│   │   │   │   ├── java/.../MainActivity.kt
│   │   │   │   ├── kotlin/.../MainActivity.kt
│   │   │   │   ├── res/
│   │   │   │   │   ├── drawable/
│   │   │   │   │   ├── layout/
│   │   │   │   │   └── values/
│   │   │   │   └── assets/    ← 仅若手动放置原生 asset
│   │   └── proguard-rules.pro
│   ├── build.gradle
│   ├── gradle/
│   │   └── wrapper/
│   │       └── gradle-wrapper.properties
│   ├── gradle.properties
│   ├── local.properties
│   ├── settings.gradle
│   └── keystores/  ← 若配置了签名文件
├── ios/
├── lib/
├── test/
├── pubspec.yaml
└── ...
  • android/build.gradle:顶层 Gradle 脚本,定义全局 Gradle 版本、插件仓库等。
  • android/app/build.gradle:应用(module)级脚本,指定 SDK 版本、依赖、签名、构建类型等。
  • android/app/src/main/:包含 Android 原生资源与入口代码:AndroidManifest.xmlMainActivity.kt / MainActivity.javares/ 资源目录。
  • gradle.properties:Gradle 全局属性,如开关并行编译、缓存配置。
  • local.properties:本地 DSL 文件,通常自动写入 Android SDK 路径与 Flutter SDK 路径。

以下章节将逐个文件深入解析其作用与典型配置。


二、关键配置文件详解

2.1 android/build.gradle(顶层 Gradle 脚本)

该文件位于 my_flutter_app/android/build.gradle,内容示例如下:

// android/build.gradle
buildscript {
    ext {
        // 定义 Kotlin 插件版本,可供模块脚本引用
        kotlin_version = '1.7.10'
        // Flutter Gradle 插件版本(不常修改)
        flutter_embedding_version = '2.0.0'
    }
    repositories {
        google()
        mavenCentral()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:7.4.2'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        // Flutter Gradle 插件,处理 Dart AOT、打包 assets 逻辑
        classpath 'org.jetbrains.kotlin:kotlin-stdlib:1.7.10'
    }
}

allprojects {
    repositories {
        google()
        mavenCentral()
    }
}

// 关闭 Kotlin 版本冲突警告(可选)
subprojects {
    project.plugins.whenPluginAdded { plugin ->
        if (plugin.class.name == 'org.jetbrains.kotlin.gradle.plugin.KotlinBasePluginWrapper') {
            project.extensions.getByType(org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension).jvmTarget = "1.8"
        }
    }
}
  • buildscript:声明构建脚本所需依赖,如 Android Gradle 插件(com.android.tools.build:gradle)和 Kotlin 插件。
  • ext:用于定义可以在子项目(如 app/build.gradle)中引用的全局变量(如 kotlin_version)。
  • allprojects.repositories:统一配置 Maven 源,确保各模块都能拉取依赖。
  • 若需要使用私有 Maven 库,也可在此处统一添加。

2.2 android/app/build.gradle(模块级 Gradle 脚本)

位于 my_flutter_app/android/app/build.gradle,示例内容:

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'

// Flutter 插件会在这行下方插入脚本,负责引入 Flutter 依赖
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"

android {
    compileSdkVersion 33

    sourceSets {
        main.java.srcDirs += 'src/main/kotlin'
    }

    defaultConfig {
        applicationId "com.example.my_flutter_app"
        minSdkVersion 21
        targetSdkVersion 33
        versionCode 1
        versionName "1.0"
        // 仅打 Release 时启用 Multidex(若方法数超限)
        multiDexEnabled true
    }

    signingConfigs {
        release {
            // release 签名配置(若有 keystore)
            keyAlias 'alias_name'
            keyPassword '*****'
            storeFile file('../keystores/release.keystore')
            storePassword '*****'
        }
    }

    buildTypes {
        debug {
            applicationIdSuffix ".debug"
            versionNameSuffix "-debug"
        }
        release {
            // release 打包时开启混淆与压缩
            minifyEnabled true
            useProguard true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
            signingConfig signingConfigs.release
        }
    }

    // 多渠道示例(可选)
    flavorDimensions "version"
    productFlavors {
        dev {
            dimension "version"
            applicationIdSuffix ".dev"
            versionNameSuffix "-dev"
        }
        prod {
            dimension "version"
        }
    }

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = "1.8"
    }
}

flutter {
    source '../..'  // 引用 Flutter 模块的根目录
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
    implementation 'androidx.multidex:multidex:2.0.1' // 如果启用 Multidex
}
  • apply from: '.../flutter.gradle'

    • 这是 Flutter Gradle 插件脚本,负责:

      1. 将 Dart 代码转为 AOT(Release)或 JIT(Debug)动态库;
      2. 打包 flutter_assets 到 APK 中;
      3. 自动为 Debug 构建添加 android.debug.observatoryHost 等必要配置。
  • compileSdkVersionminSdkVersiontargetSdkVersion:要与 Flutter 推荐保持一致。
  • signingConfigs:配置 Release 签名时所需的 keystore 信息。
  • buildTypes.release

    • minifyEnabled true:启用代码压缩(R8)
    • useProguard true:允许使用自定义 ProGuard 规则
  • productFlavors:示例展示如何做“Dev / Prod”两个构建变体(可选)。
  • flutter { source '../..' }:告诉 Gradle 当前 Android 模块是一个 Flutter 模块,源码在项目根目录。

2.3 android/gradle.propertiesandroid/local.properties

  • gradle.properties:全局 Gradle 属性。例如:

    org.gradle.jvmargs=-Xmx1536M
    android.enableR8=true
    kotlin.code.style=official
    • android.enableR8=true:启用 R8 混淆与压缩;
    • org.gradle.daemon=trueorg.gradle.parallel=true:可加速大项目构建。
  • local.properties:由 Flutter 工具自动生成,不应提交到版本控制,内容大致如下:

    sdk.dir=/Users/username/Library/Android/sdk
    flutter.sdk=/Users/username/flutter
    flutter.buildMode=debug
    flutter.versionName=1.0
    flutter.versionCode=1
    • sdk.dir:本地 Android SDK 路径;
    • flutter.sdk:本地 Flutter SDK 路径;
    • flutter.buildMode:当前构建模式;
    • 注意:不同开发者机器路径不同,因此 local.properties 不要加入 Git。

2.4 AndroidManifest.xml

位于 android/app/src/main/AndroidManifest.xml,示例:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.my_flutter_app">
    <!-- 权限示例 -->
    <uses-permission android:name="android.permission.INTERNET"/>

    <application
        android:name="${applicationName}"
        android:label="MyFlutterApp"
        android:icon="@mipmap/ic_launcher"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:theme="@style/LaunchTheme">
        <!-- Splash Screen 配置 -->
        <meta-data
            android:name="io.flutter.embedding.android.SplashScreenDrawable"
            android:resource="@drawable/launch_background"/>

        <!-- 默认 FlutterActivity 启动项 -->
        <activity
            android:name=".MainActivity"
            android:launchMode="singleTop"
            android:theme="@style/NormalTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            <!-- Intent 过滤:主入口 -->
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <!-- DeepLink / URL Scheme 可在此处添加更多 intent-filter -->
    </application>
</manifest>
  • android:name="${applicationName}":Flutter Gradle 插件会将其替换为 io.flutter.app.FlutterApplication 或自定义 Application
  • <meta-data>:Flutter 用来指定「启动画面」资源。
  • <activity android:name=".MainActivity"

    • launchMode="singleTop":确保多次启动只保持一个 Flutter 实例;
    • configChanges="…":列举了多种系统配置变更(如屏幕旋转、字体大小变化)下,Activity 不销毁重建,而由 Flutter 端自行处理。
    • windowSoftInputMode="adjustResize":当键盘弹出时让 Flutter 界面自动调整。

三、Flutter 与 Android 原生的对接

3.1 MainActivity.kt / MainActivity.java:Flutter 引擎启动入口

默认创建的 MainActivity.kt 位于 android/app/src/main/kotlin/.../MainActivity.kt,内容示例(Kotlin):

package com.example.my_flutter_app

import io.flutter.embedding.android.FlutterActivity

class MainActivity: FlutterActivity() {
    // 如无需自定义行为,可留空
}
  • 基类 FlutterActivity

    • 负责创建并持有一个 FlutterEngine 实例;
    • onCreate() 时调用 configureFlutterEngine()loadFlutterEngine(),最终启动 Dart 代码;
    • flutter_assets 中的资源挂载到 FlutterView,并初始化 Dart VM

如果企业项目需要扩展,可以覆写:

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)
    // 注册自定义插件
    GeneratedPluginRegistrant.registerWith(flutterEngine)
    // 或手动注册 MethodChannel
}

3.2 io.flutter.embedding.android.FlutterActivity 工作机制

  • FlutterActivityonCreate() 流程简化如下:

    1. 创建 FlutterEngine(或复用已有的 FlutterEngineGroup);
    2. 设置 FlutterView:一个继承自 SurfaceView 的渲染视图,用于展示 Flutter 渲染结果;
    3. 加载 Dart Entrypoint:例如 lib/main.dart,启动 Dart VM 并加载 AOT Snapshot(Release)或 JIT Kernel(Debug);
    4. 将 Channel 注册到 FlutterEngine:自动调用 GeneratedPluginRegistrant,把 pubspec.yaml 中依赖的插件注册进入;
    5. 建立 Native ↔ Dart 通信抽象:注册系统管道(MethodChannel、EventChannel、BasicMessageChannel)用于双方交互。

此后,Android 端的 UI 生命周期与 Flutter 端的渲染循环并行:当 FlutterActivity 进入前台,Dart 端 WidgetsBinding 会开始 runApp();当后台时,暂停渲染。

3.3 Flutter Gradle 插件 (flutter.gradle) 的作用

位于 $flutterRoot/packages/flutter_tools/gradle/flutter.gradle,主要职责:

  1. 定义 Gradle 任务

    • flutterBuildDebugflutterBuildRelease:调用 flutter assemble 将 Dart 代码编译成 AOT Snapshot(Release)或 Kernel(Debug);
    • flutterBuildBundle:打包 flutter_assetsbuild/flutter_assets
    • flutterBuildXgboost(仅示例);
  2. 拷贝 flutter_assets 到 APK

    • preBuildmergeAssets 之间,将 build/flutter_assets 目录插入到 Android 资源合并中;
  3. 自动生成 GeneratedPluginRegistrant.java.kt

    • 收集所有 Pub 依赖的插件在 Android 平台上的注册代码;
  4. 设置 Flutter 工程版本

    • pubspec.yaml 读取 version: x.y.z+buildNumber,影响 APK 的 versionNameversionCode

四、Gradle 构建流程全解析

4.1 构建命令与任务链:flutter build apk → Gradle Task

当你在项目根执行:

flutter build apk --release

会发生以下主要步骤:

  1. Flutter 工具层

    • 解析 pubspec.yaml,获取 versionNameversionCode 等;
    • 生成或更新 android/local.propertiesflutter.buildMode=release
    • 调用 gradlew assembleRelease(Linux/macOS)或 gradlew.bat assembleRelease(Windows)。
  2. Gradle 全局初始化

    • 读取 android/local.propertiesandroid/gradle.properties
    • 加载顶层 build.gradle 与子项目脚本;
    • 配置 Kotlin、Android Gradle 插件。
  3. Module :app 构建

    • flutterBuildRelease 任务:先执行 Dart AOT 编译

      • build/flutter_assets/ 目录生成 vm_snapshot_dataisolate_snapshot_dataapp.so(Native library)或 kernel_blob.bin(Debug);
    • processReleaseFlutterAssets 任务:将 build/flutter_assets/ 整个目录复制到 app/src/main/assets/flutter_assets/
    • mergeReleaseAssetsmergeReleaseResources:将 Flutter 资源与其它 Android 资源合并;
    • compileReleaseKotlin / compileReleaseJava:编译 Java/Kotlin 源码;
    • mergeReleaseJniLibFolders:将不同 ABI(如 arm64-v8aarmeabi-v7ax86_64)的 app.so(Dart AOT 编译产物)合并到对应 lib/ 目录;
    • minifyReleaseWithR8:对 Java/Kotlin 字节码进行压缩与混淆(如果开启);
    • packageRelease:将 classes.jarresources.ap_flutter_assetsjniLibs 等打包为 app-release-unsigned.apk
    • vaReleaseEnable & zipalignRelease:对齐 APK 并生成最终 app-release.apk
    • signRelease:使用 signingConfigs.release 配置将 APK 签名。

构建流程图(简化)

flutter build apk --release
          ↓
   flutter.gradle → flutterBuildRelease  (Dart AOT 编译)
          ↓
processReleaseFlutterAssets (复制 flutter_assets)
          ↓
 mergeReleaseAssets / mergeReleaseResources
          ↓
compileReleaseKotlin / compileReleaseJava
          ↓
 mergeReleaseJniLibFolders (合并 .so 到 lib/armeabi-v7a/...)
          ↓
  minifyReleaseWithR8 (可选)
          ↓
     packageRelease (生成 .apk)
          ↓
   zipalignRelease (对齐)
          ↓
    signRelease (签名)
          ↓
= 输出: app-release.apk =

4.2 AOT 编译:从 Dart 到 ARM/ASM 的转换

在 Release 模式下,Flutter 会将 Dart 代码Ahead-Of-Time(AOT)编译成本地机器码,生成一个共享库 .so,以最大化性能和启动速度。

  • 过程

    1. Dart 前端:将 Dart 源码转成 Kernel IR(中间表示);
    2. Dart AOT 编译器:接收 Kernel IR,生成机器指令,输出到 ELF 格式的共享库(如 app.so);
    3. 生成的 .so 会被放在 build/app/intermediates/cmake/release/obj/<abi>/libapp.so,并通过 Gradle 合并进最终 APK。
  • 区别

    • Debug 模式:使用 JIT 编译,Dart VM 在运行时即时编译,生成 kernel_blob.bin
    • Profile 模式:生成半 AOT(仅一部分热重载支持)\`;
    • Release 模式:全 AOT,无热重载,性能最优。

4.3 打包 Asset:如何将 flutter_assets 注入到 APK 中

  • Sourceflutter_assets 目录内容由 flutter pub get 与构建步骤生成,包括:

    • FontManifest.jsonAssetManifest.json
    • 应用自定义的静态资源,如 assets/images/…
    • Dart 预编译产物(AOT 或 Kernel blob)
  • Destination:最终放置在 APK 内的路径为:

    assets/flutter_assets/  ← 该目录下所有资源直接映射到 Flutter 端
    lib/armeabi-v7a/libapp.so
    lib/arm64-v8a/libapp.so
    ...
  • 在运行时,FlutterEngine 会在启动时通过 FlutterLoader 加载 flutter_assets 路径下的资源,例如 main.dart.snapshot、图片、字体。

4.4 原生库链接:ABI 架构划分

为了支持不同架构的 Android 设备,Flutter 会针对各个 ABI 生成对应的 libapp.so,并放在:

app/src/main/jniLibs/armeabi-v7a/libflutter.so
app/src/main/jniLibs/arm64-v8a/libflutter.so
app/src/main/jniLibs/x86/libflutter.so
app/src/main/jniLibs/x86_64/libflutter.so

其中,libflutter.so 是 Flutter Engine 本身体积较大的组件,负责在 Native 层驱动 Dart VM 与 Skia 渲染。

  • Gradle 合并

    • mergeReleaseJniLibFolders 任务中,会将上述目录下的 .so(Engine 与 AOT 应用库)复制到 build/intermediates/merged_native_libs/release/out/lib/<abi>/
    • 最终打包到 apk/lib/<abi>/ 下。

4.5 签名与对齐:signingConfigszipalign

  • zipalign:一个官方工具,用于对齐 APK 内各数据块到 4 字节分界,使设备在运行时能更快地读取打包资源。
  • 签名:使用 JKS(keystore.jks)文件对 APK 进行数字签名。示例配置在 build.gradle 中的 signingConfigs.release

    signingConfigs {
        release {
            keyAlias 'release_key_alias'
            keyPassword 'your_key_password'
            storeFile file('../keystores/release.jks')
            storePassword 'your_store_password'
        }
    }
  • 最终 Release 模式下会输出经过对齐签名app-release.apk

4.6 多渠道打包(Gradle flavor)示例

app/build.gradle 中添加 productFlavors:

android {
    ...
    flavorDimensions "flavor"
    productFlavors {
        free {
            dimension "flavor"
            applicationIdSuffix ".free"
            versionNameSuffix "-free"
        }
        paid {
            dimension "flavor"
            applicationIdSuffix ".paid"
            versionNameSuffix "-paid"
        }
    }
}
  • 打包时可执行:

    flutter build apk --flavor free -t lib/main_free.dart
    flutter build apk --flavor paid -t lib/main_paid.dart
  • 这样会分别生成 app-free-release.apkapp-paid-release.apk,可在代码中通过 packageInfoBuildConfig.FLAVOR 区分渠道。

五、Java / Kotlin 与 Dart 通信通道

Flutter 应用常常需要调用 Android 原生 API,反之亦然。Flutter 提供多种通信方式,最常见的为 MethodChannel

5.1 MethodChannelEventChannelBasicMessageChannel 源码路径

位于 Flutter Engine Android 端的相关源码:

<flutter_sdk>/packages/flutter/lib/src/services/
  ├── method_channel.dart          ← Dart 端对 MethodChannel 的封装
  ├── event_channel.dart           ← Dart 端对 EventChannel 的封装
  ├── basic_message_channel.dart    ← Dart 端对 BasicMessageChannel 的封装

对应的 Android 端注册类:

<flutter_sdk>/shell/platform/android/io/flutter/plugin/common/
  ├── MethodChannel.java
  ├── EventChannel.java
  ├── BasicMessageChannel.java

5.2 Android 端插件注册流程(GeneratedPluginRegistrant

每次 flutter pub get 时,Flutter 插件系统会扫描 pubspec.yaml 中的插件依赖,并自动生成一段 Java/Kotlin 代码,将所有插件的 registerWith 方法调度到主引擎中。

示例 GeneratedPluginRegistrant(Kotlin)位置:

android/app/src/main/kotlin/io/flutter/plugins/GeneratedPluginRegistrant.kt

示例内容:

package io.flutter.plugins

import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugins.connectivity.ConnectivityPlugin
import io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin
// ...

object GeneratedPluginRegistrant {
  fun registerWith(flutterEngine: FlutterEngine) {
    ConnectivityPlugin.registerWith(flutterEngine.dartExecutor.binaryMessenger)
    FlutterFirebaseCorePlugin.registerWith(flutterEngine.dartExecutor.binaryMessenger)
    // ...
  }
}

MainActivity.kt 通常自动调用此方法以统一注册插件。也可手动在 configureFlutterEngine() 中添加。

5.3 Native 调试:如何在 Android Studio 断点 Dart 调用

  1. 在 Dart 端,使用 MethodChannel('com.example.channel').invokeMethod('methodName', args)
  2. 在 Android 端,在 MainActivity 或插件注册处,覆写 MethodChannel.setMethodCallHandler { call, result -> ... }
  3. 在 Android Studio 中可以在 Native 代码(Kotlin/Java)侧打断点。
  4. 先运行 flutter run --debug,然后附加 Android Studio 调试,切换至 “Android” 视图,选择相应进程,点击“Debug”。
  5. 当 Dart 端发起调用时,Native 端会命中断点,便于双端联调。

六、资源、ABI 与包结构细节

6.1 app/src/main/res:Drawable、layout、values 等目录

  • drawable/:存放 PNG、JPEG、XML Drawable(如 shape、selector)。
  • layout/:存放原生 Android 布局文件(通常 Flutter 不用,但自定义插件可能会用到)。
  • values/:存放字符串(strings.xml)、主题样式(styles.xml)、颜色(colors.xml)、尺寸(dimens.xml)等。

Flutter 应用的 UI 主要由 Dart 端渲染,Native 端只需在特定场景下使用原生布局时才会用到,否则可留空或删除无用文件。

6.2 lib/flutter_assets/:Asset Catalog 打包原理

  • 本地资源(图片、JSON、字体)在 Dart 侧通过 pubspec.yamlassets:fonts: 声明后,flutter build 会将其复制到 build/flutter_assets/
  • 最终它们位于 APK 内的 assets/flutter_assets/ 目录中。Flutter Engine 启动时会通过 FlutterLoader 注册该路径,并提供给 Dart VM 加载使用。

6.3 jniLibs/:原生库目录与多架构支持

如果你在插件或原生模块中直接编译了 .so 库,可以放在:

app/src/main/jniLibs/armeabi-v7a/libmylib.so
app/src/main/jniLibs/arm64-v8a/libmylib.so
app/src/main/jniLibs/x86/libmylib.so
app/src/main/jniLibs/x86_64/libmylib.so

Gradle 会自动将这些库拷贝到最终 APK 的相应目录下。Flutter AOT 编译产物(libapp.so)由插件脚本统一管理,不建议手动放置。

6.4 APK 内部目录树示意

以下示意为打开一个 Release 模式 Flutter APK 后的大致文件结构:

app-release.apk/
├── META-INF/
│   ├── CERT.RSA
│   ├── CERT.SF
│   └── MANIFEST.MF
├── lib/
│   ├── armeabi-v7a/
│   │   ├── libapp.so           ← Dart AOT 产物
│   │   ├── libflutter.so       ← Flutter Engine
│   │   └── libmylib.so         ← 自定义插件的原生库(若有)
│   ├── arm64-v8a/
│   │   └── ...
│   ├── x86_64/
│   │   └── ...
│   └── x86/
│       └── ...
├── assets/
│   └── flutter_assets/        ← 所有 Flutter 资源
│       ├── FontManifest.json
│       ├── AssetManifest.json
│       ├── flutter_assets.dill
│       ├── icudtl.dat
│       ├── main.dart.snapshot
│       ├── icons/
│       └── images/
├── res/
│   ├── drawable/
│   ├── layout/
│   └── values/
├── AndroidManifest.xml
├── classes.dex                ← Dalvik 字节码(仅用于插件代码或自定义 Java/Kotlin)
└── resources.arsc
  • classes.dex 包含原生插件的 Java/Kotlin 字节码。Flutter 本身的 UI 逻辑都编译为 AOT .so,不会出现在 DEX 中。

七、调优与常见问题

7.1 构建速度优化

  • 开启 Gradle 守护进程(Daemon)与并行构建
    gradle.properties 中添加:

    org.gradle.daemon=true
    org.gradle.parallel=true
  • 开启构建缓存

    org.gradle.caching=true

    使常见任务有缓存,加快增量编译。

  • 只编译指定 ABI
    如果只针对单个 ABI(如 arm64-v8a)进行调试,可在 app/build.gradle 中配置:

    ndk {
        abiFilters "arm64-v8a"
    }

    避免每次都编译所有架构的 .so,显著节省时间。

7.2 减少 APK 体积

  • 拆分 ABI

    flutter build apk --split-per-abi

    会生成多个小 APK,每个只包含一个 ABI 的 .so,减小单个包大小。

  • 开启代码压缩
    build.gradle 中启用:

    buildTypes {
        release {
            minifyEnabled true
            useProguard true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }

    R8 会移除未使用的 Java/Kotlin 字节码,但不会影响 AOT 产物。

  • 压缩资源
    使用 Android Studio 的 APK Analyzer 查看 assets/flutter_assets 大小,去除不必要的资源或使用更小的图片格式(如 WebP)。

7.3 性能剖析:Systrace、APK Analyzer、Profile Mode

  • Profile Mode

    flutter run --profile

    启动 Profile 模式,能够在 DevTools 中查看 CPU、GPU、内存、Dart VM 的性能指标。

  • Systrace
    通过 flutter run --profile 后,连接到 Android 设备,使用 flutter trace 或 Android Studio 的 CPU Profiler 采集设备层面的系统调用时间线,定位渲染卡顿或 jank。
  • APK Analyzer
    在 Android Studio 中:Build → Analyze APK...,打开生成的 app-release.apk。可以查看:

    • .so 大小(Engine vs AOT vs 自定义库)
    • assets/flutter_assets 大小分布
    • DEX 方法数,看是否需要启用 Multidex

7.4 常见打包错误 & 解决方案

  1. Execution failed for task ':app:mergeReleaseAssets'

    • 原因:可能是 flutter_assets 与原生 res/ 中出现了重名资源;
    • 解决:确保资源路径唯一,或升级 Flutter 插件版本。
  2. Unable to merge dex(方法数超限)

    • 原因:插件或依赖库太多导致 DEX 方法总数超出 65K;
    • 解决:启用 Multidex(multiDexEnabled true 并在 defaultConfig 中添加 implementation 'androidx.multidex:multidex:2.0.1';并在 Application 中继承 MultiDexApplication)。
  3. Your project requires a newer version of the Kotlin Gradle plugin

    • 原因:Gradle 或 Kotlin 插件版本不匹配;
    • 解决:升级 ext.kotlin_versioncom.android.tools.build:gradle 到兼容版本。

八、实战示例:自定义原生插件打包

假设我们要编写一个自定义 Flutter 插件,调用 Android 原生 API 获取电池电量,并在 Dart 端显示。

8.1 插件目录与 pubspec.yaml 配置

my_flutter_app/
├── android/
│   └── app/
│       └── src/main/kotlin/com/example/my_flutter_app/BatteryPlugin.kt
├── lib/
│   └── battery_plugin.dart
├── pubspec.yaml

pubspec.yaml 中添加:

dependencies:
  flutter:
    sdk: flutter

# 指定插件目录
flutter:
  plugin:
    platforms:
      android:
        package: com.example.my_flutter_app
        pluginClass: BatteryPlugin

8.2 Kotlin 端代码示例与注册

android/app/src/main/kotlin/com/example/my_flutter_app/BatteryPlugin.kt 内容:

package com.example.my_flutter_app

import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build
import androidx.annotation.NonNull
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel

class BatteryPlugin: FlutterPlugin, MethodChannel.MethodCallHandler {
  private lateinit var channel : MethodChannel
  private lateinit var context: Context

  override fun onAttachedToEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
    context = binding.applicationContext
    channel = MethodChannel(binding.binaryMessenger, "battery_plugin")
    channel.setMethodCallHandler(this)
  }

  override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
    if (call.method == "getBatteryLevel") {
      val batteryLevel = getBatteryLevel()
      if (batteryLevel != -1) {
        result.success(batteryLevel)
      } else {
        result.error("UNAVAILABLE", "Battery level not available.", null)
      }
    } else {
      result.notImplemented()
    }
  }

  private fun getBatteryLevel(): Int {
    val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
      batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
    } else {
      val intent = ContextWrapper(context).registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
      intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)?.let { level ->
        val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
        (level * 100) / scale
      } ?: -1
    }
  }

  override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
    channel.setMethodCallHandler(null)
  }
}
  • BatteryPlugin:实现 FlutterPlugin,在 onAttachedToEngine 中创建 MethodChannel 并注册回调。
  • Dart 端通过 MethodChannel('battery_plugin') 调用 getBatteryLevel 方法,即可获取电量。

8.3 Gradle 修改:添加依赖、混淆设置

app/build.gradle 中,插件的依赖已经通过 Flutter 插件系统自动注册,不需在 dependencies{} 中手动添加。

若 Release 模式开启混淆,需在 proguard-rules.pro 中添加保护:

-keep class com.example.my_flutter_app.BatteryPlugin { *; }

确保插件类在混淆时不会被移除或重命名。

8.4 编译输出验证:查看 Native 库与 Dart Bundle

执行:

flutter build apk --release

生成的 APK 中,可以使用 apkanalyzer 或解压观察:

unzip -l build/app/outputs/flutter-apk/app-release.apk
  • 找到 lib/arm64-v8a/libapp.solib/arm64-v8a/libflutter.so
  • 确认在 assets/flutter_assets/ 下存在 flutter_assets 目录与 kernel_blob.bin
  • classes.dex 中使用 dexdump 或 Android Studio 的 DEX Viewer,确保 BatteryPlugin 类存在。

九、总结

本文从Flutter Android 工程的顶层目录关键 Gradle 脚本原生与 Dart 对接机制Gradle 构建流程多架构打包与签名资源与 ABI 细节,乃至插件开发实践性能优化与常见问题等多个维度,全面解析了 Flutter 应用在 Android 端的实现原理与源码要点。通过深入了解这些内容,你将能够:

  • 灵活配置构建参数:根据应用场景定制 minSdkVersion、开启 R8 混淆、进行多渠道打包。
  • 高效排查构建错误:掌握 Gradle 任务链与日志输出,快速定位合并资源、签名失败、方法数超限等问题。
  • 扩展原生能力:通过自定义插件或直接在 MainActivity 中使用 MethodChannel,实现 Dart ↔ Android 互调。
  • 优化性能与体积:通过 AOT、ABI 拆分、资源压缩等手段,保持应用的小体积与高性能。

掌握这份“Flutter Android 工程深度解析”,可以让你更有底气去面对生产级项目,更快定位问题、更灵活扩展原生功能,也为学习更底层的 Flutter Engine 源码打下坚实基础。

2025-05-31

Android 原生功能与 Vue 交互实现全攻略


目录

  1. 前言
  2. 技术选型与环境准备

    • 2.1 技术选型
    • 2.2 环境准备
  3. 整体架构与通信原理

    • 3.1 高层架构图解
    • 3.2 双向通信原理
  4. Vue 端:项目初始化与基础封装

    • 4.1 新建 Vue 3 项目
    • 4.2 创建与 Android 通信的封装模块
    • 4.3 在组件中调用原生接口示例
  5. Android 端:WebView 集成与原生接口暴露

    • 5.1 新建 Android 项目并引入 WebView
    • 5.2 配置 WebView 与开启 JavaScript
    • 5.3 通过 @JavascriptInterface 暴露原生方法
    • 5.4 Android 调用 Vue 的方法(evaluateJavascript)
  6. 综合示例:获取设备信息与拍照功能

    • 6.1 需求分析
    • 6.2 Android 端实现
    • 6.3 Vue 端实现
    • 6.4 数据流动图解
  7. 进阶示例:定位功能与实时回调

    • 7.1 需求分析
    • 7.2 Android 端实现(Location + 权限)
    • 7.3 Vue 端实现与展示
    • 7.4 通信流程图解
  8. 常见问题与调试方法
  9. 总结与最佳实践

前言

随着前端框架的发展,使用 Vue 构建移动端界面已经越来越普及。然而,一旦需要调用 Android 原生功能(如摄像头、定位、传感器、推送通知等),就必须在 Web(Vue)与 Android 之间建立一条“桥梁”,通过双向通信才可实现二者无缝交互。本文将从零开始,手把手讲解如何在 Android 原生项目中嵌入 Vue 应用, 并实现 Vue ↔ 原生 的双向通信。无论你是初学者还是有一定经验的同学,都能通过本文对应的「完整示例」迅速掌握关键点。


技术选型与环境准备

2.1 技术选型

  • 前端框架:Vue 3 + Vite
  • 后端/中间层:Android 原生
  • 通信方式

    1. Vue → Android:调用 window.Android.xxx(),Android 端通过 @JavascriptInterface 注解的方法接收。
    2. Android → Vue:使用 webView.evaluateJavascript("window.onNativeCallback(...)")webView.loadUrl("javascript:...") 等方式触发 Vue 中定义的回调函数。
  • 构建工具

    • Vue:Vite(极简、极速热更新)
    • Android:Android Studio(建议 2022+ 版本,Gradle plugin 7.x)

2.2 环境准备

  1. Android Studio

    • Android Studio Arctic Fox 或更高版本
    • 配置好 Java 1.8+ JDK
  2. Node.js + NPM/Yarn

    • Node.js 14+
    • 全局安装 pnpm/npm/yarn 中任意一种包管理工具
  3. Vue CLI (可选)

    • 若想使用 Vue CLI 创建,可全局安装 @vue/cli,不过本文直接采用 Vite 初始化。
  4. 真机或模拟器

    • Android 模拟器(API 21+ 即可)或 真机调试
  5. 网络环境

    • 建议将 Vue 构建产物放到 Android 项目中的 assets 目录做本地加载,也可通过远程服务器来加载(开发阶段推荐本地)。

整体架构与通信原理

3.1 高层架构图解

┌──────────────────────────────────────────┐
│             Android 原生项目              │
│  ┌────────────────────────────────────┐  │
│  │            Android Activity        │  │
│  │ ┌────────────────────────────────┐ │  │
│  │ │         WebView (容器)           │ │  │
│  │ │ ┌───────────────┐  ┌───────────┐ │ │  │
│  │ │ │  index.html   │  │  JS  脚本 │ │ │  │
│  │ │ │ (Vue App)     │  │           │ │ │  │
│  │ │ └───────────────┘  └───────────┘ │ │  │
│  │ └────────────────────────────────┘ │  │
│  └────────────────────────────────────┘  │
│                                          │
│    原生功能调用(摄像头、定位、传感器等)    │
│             双向通信桥梁                    │
└──────────────────────────────────────────┘
  • Android 项目中,通过 WebView 将 Vue 编译产物(HTML + JS + CSS)加载到移动端。
  • 双向通信

    1. Vue → Android

      • 在 Vue 代码里调用 window.Android.someMethod(...),Android 端通过 @JavascriptInterface 注解的方法接收请求并执行原生功能。
    2. Android → Vue

      • Android 原生在异步操作完成(例如获取定位、拍照、扫描二维码)后,通过 webView.evaluateJavascript("window.onNativeCallback(...)", null)webView.loadUrl("javascript:window.onNativeCallback('...')"),将结果回传给 Vue。Vue 在页面里绑定了 window.onNativeCallback 函数来处理数据。

3.2 双向通信原理

  1. Vue → Android

    • Vue 端直接访问 window.Android 对象下的方法;
    • Android 端在 WebView 中注入一个 Java 对象(例如 JSBridge),在该对象上定义若干 @JavascriptInterface 的方法;
    • 当 Vue 端调用 window.Android.openCamera() 时,Android 会收到这次调用并执行相应原生逻辑。
  2. Android → Vue

    • Android 原生代码可调用:

      webView.evaluateJavascript("window.onNativeCallback('" + jsonResult + "')", null);

      或:

      webView.loadUrl("javascript:window.onNativeCallback('" + jsonResult + "')");
    • Vue 端在页面全局(例如 main.js)注册了 window.onNativeCallback = function (data) { /* … */ },当 Android 推送这段脚本时,Vue 端即可即时收到并处理。

Vue 端:项目初始化与基础封装

4.1 新建 Vue 3 项目

  1. 使用 Vite 创建 Vue 3 项目:

    npm create vite@latest vue-android-bridge --template vue
    cd vue-android-bridge
    npm install
  2. 修改 vite.config.js,确保打包后项目能放到 Android 端 assets 目录下。一般无需做特殊配置,默认为:

    import { defineConfig } from 'vite';
    import vue from '@vitejs/plugin-vue';
    
    export default defineConfig({
      plugins: [vue()],
      build: {
        outDir: 'dist',
        assetsDir: 'assets',
        // 如果需要 Base 路径指定为相对路径:
        base: './'
      }
    });
  3. package.json 中添加打包脚本(通常已经有):

    {
      "scripts": {
        "dev": "vite",
        "build": "vite build"
      }
    }
  4. 运行并验证:

    npm run dev
    # 浏览器中访问 http://localhost:3000,确认正常。
  5. 打包产物:

    npm run build
    # 将会在项目根目录生成 dist/ 文件夹,包含 index.html、assets/...

4.2 创建与 Android 通信的封装模块

为了让在各个 Vue 组件中调用原生接口时更加方便,我们可以统一封装一个 jsbridge.js 文件,让所有调用都通过同一个接口调用原生方法,并处理回调。

src/utils/jsbridge.js 中:

// src/utils/jsbridge.js

/**
 * 这里约定 Android 端注入的对象名为:Android
 * Android 端会在 WebView 加载完成后通过 webView.addJavascriptInterface(new JSBridge(), "Android") 注入。
 * 
 * JSBridge 方法示例:
 *  - openCamera(): 调用摄像头
 *  - getDeviceInfo(): 获取设备信息
 *  - startLocation(): 启动定位
 *  - stopLocation(): 停止定位
 *  - …
 */

/** 简单检测 Android 环境 */
function isAndroid() {
  return typeof window.Android !== 'undefined';
}

/** 调用 Android 原生方法 */
function callNative(method, ...args) {
  if (isAndroid() && typeof window.Android[method] === 'function') {
    try {
      // 转换参数为字符串,因为 Android 端方法一般接收 String 类型
      const strArgs = args.map(arg => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg)));
      return window.Android[method](...strArgs);
    } catch (e) {
      console.error(`[JSBridge] 调用原生方法 ${method} 失败`, e);
    }
  } else {
    console.warn(`[JSBridge] 当前环境不支持 Android 原生调用:${method}`);
  }
}

/**
 * 注册一个全局回调 Map,用于 Android 推送数据到 JS 时进行分发
 * key: callbackId
 * value: 具体的回调函数
 */
const callbackMap = new Map();

/** 生成唯一 Callback ID */
function genCallbackId() {
  return 'cb_' + Date.now() + '_' + Math.floor(Math.random() * 10000);
}

/**
 * Vue 端给 Android 调用并携带回调
 * @param {String} method 原生方法名
 * @param {any[]} args   参数列表
 * @param {Function} callback 原生回调 JS 函数
 */
function callNativeWithCallback(method, args = [], callback) {
  const callbackId = genCallbackId();
  if (typeof callback === 'function') {
    callbackMap.set(callbackId, callback);
  }
  // 将参数列表加上 callbackId 传给原生,约定原生回调时会携带 callbackId
  callNative(method, ...args, callbackId);
}

/** 供 Android 调用:当原生通过 evaluateJavascript 调用 window.onNativeCallback 时,这里会执行分发 */
window.onNativeCallback = function (callbackId, jsonResult) {
  try {
    const result = JSON.parse(jsonResult);
    const cb = callbackMap.get(callbackId);
    if (cb) {
      cb(result);
      // 如果只需调用一次,调用完成后删除
      callbackMap.delete(callbackId);
    }
  } catch (e) {
    console.error('[JSBridge] 解析回调数据失败', e);
  }
};

export default {
  callNative,
  callNativeWithCallback
};
  • isAndroid():用于检测当前环境是否注入了 window.Android
  • callNative(method, ...args):直接调用无回调的简单原生方法。
  • callNativeWithCallback(method, args, callback):带回调的调用,会生成一个 callbackId 并缓存回调函数;Android 端执行完原生逻辑后通过 window.onNativeCallback(callbackId, JSON.stringify(data)) 将结果回传给 JS。

4.3 在组件中调用原生接口示例

假设我们想在 Vue 组件里做如下操作:

  1. 点击按钮调用 Android 原生的 getDeviceInfo 方法,获取设备型号、系统版本等信息,并在页面中展示。
  2. 点击按钮调用 Android 原生的 openCamera 方法,拍照后把照片的文件路径传回 JS,然后在页面中显示。

示例组件:src/components/NativeDemo.vue

<template>
  <div class="demo">
    <h2>Android 原生功能示例</h2>

    <div class="section">
      <button @click="fetchDeviceInfo">获取设备信息</button>
      <div v-if="deviceInfo">
        <p><strong>设备信息:</strong></p>
        <p>品牌:{{ deviceInfo.brand }}</p>
        <p>型号:{{ deviceInfo.model }}</p>
        <p>系统版本:{{ deviceInfo.osVersion }}</p>
      </div>
    </div>

    <hr />

    <div class="section">
      <button @click="takePhoto">拍照并获取照片</button>
      <div v-if="photoPath">
        <p><strong>照片本地路径:</strong> {{ photoPath }}</p>
        <img :src="photoUrl" alt="拍照结果" style="max-width: 300px; margin-top: 8px; border: 1px solid #ccc;" />
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import JSBridge from '@/utils/jsbridge';

const deviceInfo = ref(null);
const photoPath = ref('');
const photoUrl = ref(''); // 兼容 Android File URI

/** 获取设备信息 */
function fetchDeviceInfo() {
  JSBridge.callNativeWithCallback('getDeviceInfo', [], (result) => {
    // result 形如:{ brand: 'Xiaomi', model: 'Mi 11', osVersion: 'Android 12' }
    deviceInfo.value = result;
  });
}

/** 拍照 */
function takePhoto() {
  JSBridge.callNativeWithCallback('openCamera', [], (result) => {
    // result 形如:{ success: true, path: '/storage/emulated/0/DCIM/Camera/xxx.jpg' }
    if (result.success) {
      photoPath.value = result.path;
      // Android 特殊:File:// URI
      photoUrl.value = `file://${result.path}`;
    } else {
      alert('拍照失败:' + result.message);
    }
  });
}
</script>

<style scoped>
.demo {
  padding: 16px;
}
.section {
  margin-bottom: 24px;
}
button {
  padding: 8px 16px;
  background: #409eff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
button:hover {
  background: #66b1ff;
}
</style>
  • fetchDeviceInfo():调用 getDeviceInfo 并注册回调。
  • takePhoto():调用 openCamera 并注册回调,返回的 result.path 是 Android 端保存的照片路径,前端用 file:// 前缀展示。

小结

  • Vue 端只关注调用方法名与回调,复用 jsbridge.js 做统一调用。
  • 回调机制约定:原生收到 callbackId 后,做完逻辑再调用 window.onNativeCallback(callbackId, JSON.stringify(result))

Android 端:WebView 集成与原生接口暴露

5.1 新建 Android 项目并引入 WebView

  1. 创建项目

    • 在 Android Studio 中,选择 “New Project” → “Empty Activity”,命名为 VueAndroidBridgeDemo(包名:com.example.vueandroidbridge)。
    • 语言选择 JavaKotlin,下面示例以 Java 为主(Kotlin 代码在注释中给出对应写法)。
    • 最低 SDK 建议选择 Android 5.0(API 21)以上,确保大部分机型兼容。
  2. 布局文件

    • 打开 res/layout/activity_main.xml,用一个全屏的 WebView 来承载 Vue 页面:
    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">
    
        <WebView
            android:id="@+id/webview"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    </RelativeLayout>
  3. 申请权限

    • AndroidManifest.xml 中,根据后续要调用的功能,加入所需权限。例如拍照与读写文件、定位:

      <!-- AndroidManifest.xml -->
      <manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="com.example.vueandroidbridge">
      
          <uses-permission android:name="android.permission.CAMERA" />
          <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
          <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
          <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
          <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
      
          <application
              android:allowBackup="true"
              android:label="@string/app_name"
              android:supportsRtl="true"
              android:theme="@style/Theme.VueAndroidBridgeDemo">
              <activity android:name=".MainActivity"
                  android:exported="true">
                  <intent-filter>
                      <action android:name="android.intent.action.MAIN" />
                      <category android:name="android.intent.category.LAUNCHER" />
                  </intent-filter>
              </activity>
          </application>
      </manifest>

5.2 配置 WebView 与开启 JavaScript

MainActivity.java 中,我们需要初始化 WebView,加载本地的 index.html 或远程调试链接,并开启 JavaScript 支持、允许文件访问等。

// MainActivity.java

package com.example.vueandroidbridge;

import android.Manifest;
import android.annotation.SuppressLint;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebChromeClient;
import android.webkit.WebViewClient;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;

public class MainActivity extends AppCompatActivity {

    private WebView webView;
    private static final int REQUEST_PERMISSIONS = 1001;
    private String[] permissions = new String[]{
            Manifest.permission.CAMERA,
            Manifest.permission.WRITE_EXTERNAL_STORAGE,
            Manifest.permission.READ_EXTERNAL_STORAGE,
            Manifest.permission.ACCESS_FINE_LOCATION,
            Manifest.permission.ACCESS_COARSE_LOCATION
    };

    @SuppressLint("SetJavaScriptEnabled")
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

         // 1. 动态申请权限(Android 6.0+)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            ActivityCompat.requestPermissions(this, permissions, REQUEST_PERMISSIONS);
        }

        // 2. 初始化 WebView
        webView = findViewById(R.id.webview);
        WebSettings ws = webView.getSettings();
        ws.setJavaScriptEnabled(true);                  // 启用 JavaScript
        ws.setDomStorageEnabled(true);                  // 启用 DOM Storage
        ws.setAllowFileAccess(true);                    // 允许文件访问
        ws.setAllowContentAccess(true);
        ws.setDatabaseEnabled(true);

        // 如果调试阶段可开启 WebView 调试
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            WebView.setWebContentsDebuggingEnabled(true);
        }

        // 3. 解决部分手机点击文件上传不响应的问题
        webView.setWebChromeClient(new WebChromeClient());

        // 4. 阻止系统浏览器打开链接
        webView.setWebViewClient(new WebViewClient());

        // 5. 注入 JSBridge 接口对象到 WebView
        webView.addJavascriptInterface(new JSBridge(this, webView), "Android");

        // 6. 加载本地或远程 URL
        // 开发阶段:先加载本地 HTTP 服务器
        // webView.loadUrl("http://10.0.2.2:3000/"); // Android 模拟器访问本机 localhost:3000
        // 生产阶段:将 dist/ 下的文件放到 assets/www 目录,并加载:
        webView.loadUrl("file:///android_asset/www/index.html");
    }

    /** 权限申请结果回调(如需判断具体权限,可在此处理) */
    @Override
    public void onRequestPermissionsResult(int requestCode,
                                           @NonNull String[] permissions,
                                           @NonNull int[] grantResults) {
        if (requestCode == REQUEST_PERMISSIONS) {
            // 这里简单忽略是否全部授权,实际可优化
            super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        }
    }
}
  • 关键配置

    1. ws.setJavaScriptEnabled(true); —— 启用 JavaScript。
    2. webView.addJavascriptInterface(new JSBridge(this, webView), "Android"); —— 将后续自定义的 JSBridge 对象注入到 JS 全局的 window.Android
    3. 加载本地资源:将 Vue 打包产物拷贝到 app/src/main/assets/www/ 中,然后 loadUrl("file:///android_asset/www/index.html")
  • 调试时 可以先运行本地 Vue 项目,使用 webView.loadUrl("http://10.0.2.2:3000/"); 来实时预览修改效果。

5.3 通过 @JavascriptInterface 暴露原生方法

接下来,我们编写一个名为 JSBridge 的辅助类,用来处理 Vue 端调用的原生方法。示例以 Java 编写:(Kotlin 可参照注释转换)

// JSBridge.java

package com.example.vueandroidbridge;

import android.Manifest;
import android.app.Activity;
import android.content.ContentValues;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.MediaStore;
import android.webkit.JavascriptInterface;
import android.webkit.WebView;
import android.widget.Toast;

import androidx.core.content.ContextCompat;
import androidx.core.content.FileProvider;

import org.json.JSONObject;

import java.io.File;
import java.io.FileOutputStream;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;

public class JSBridge {
    private Activity activity;
    private WebView webView;

    // 用于拍照
    private static final int REQUEST_IMAGE_CAPTURE = 2001;
    private Uri photoUri;
    private String currentPhotoPath;
    private String currentCallbackIdForPhoto;

    // 用于定位
    private LocationManager locationManager;
    private String currentCallbackIdForLocation;

    public JSBridge(Activity activity, WebView webView) {
        this.activity = activity;
        this.webView = webView;
        // 初始化定位管理器
        locationManager = (LocationManager) activity.getSystemService(Activity.LOCATION_SERVICE);
    }

    /**
     * 获取设备信息
     * 约定:Vue 端调用 getDeviceInfo(callbackId),这里直接构造 JSON 并回调给 JS
     */
    @JavascriptInterface
    public void getDeviceInfo(String callbackId) {
        try {
            JSONObject result = new JSONObject();
            result.put("brand", Build.BRAND);
            result.put("model", Build.MODEL);
            result.put("osVersion", Build.VERSION.RELEASE);
            // 回传给 JS
            callbackToJs(callbackId, result.toString());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 打开摄像头,拍照并保存到本地
     * 约定:Vue 端调用 openCamera(callbackId)
     */
    @JavascriptInterface
    public void openCamera(String callbackId) {
        // 保存当前 callbackId,拍照后再回调
        currentCallbackIdForPhoto = callbackId;
        Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        if (takePictureIntent.resolveActivity(activity.getPackageManager()) != null) {
            // 创建临时文件
            try {
                File photoFile = createImageFile();
                if (photoFile != null) {
                    // 7.0+ 需通过 FileProvider 获取 Uri
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                        photoUri = FileProvider.getUriForFile(activity,
                                activity.getPackageName() + ".fileprovider", photoFile);
                    } else {
                        photoUri = Uri.fromFile(photoFile);
                    }
                    takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri);
                    activity.startActivityForResult(takePictureIntent, REQUEST_IMAGE_CAPTURE);
                }
            } catch (Exception ex) {
                ex.printStackTrace();
                // 拍照失败,立刻回调错误
                sendPhotoResult(false, "创建文件失败:" + ex.getMessage());
            }
        } else {
            sendPhotoResult(false, "没有相机应用");
        }
    }

    /** 拍照后回调结果 */
    private void sendPhotoResult(boolean success, String message) {
        try {
            JSONObject result = new JSONObject();
            result.put("success", success);
            if (success) {
                result.put("path", currentPhotoPath);
            } else {
                result.put("message", message);
            }
            callbackToJs(currentCallbackIdForPhoto, result.toString());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /** 处理 onActivityResult,获取拍照结果 */
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        if (requestCode == REQUEST_IMAGE_CAPTURE) {
            if (resultCode == Activity.RESULT_OK) {
                // 照片已保存到 currentPhotoPath
                sendPhotoResult(true, "");
            } else {
                sendPhotoResult(false, "用户取消拍照");
            }
        }
    }

    /** 创建图片文件 */
    private File createImageFile() throws Exception {
        String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(new Date());
        String imageFileName = "JPEG_" + timeStamp + "_";
        File storageDir = activity.getExternalFilesDir(Environment.DIRECTORY_PICTURES);
        File image = File.createTempFile(
                imageFileName,
                ".jpg",
                storageDir
        );
        // 保存文件路径
        currentPhotoPath = image.getAbsolutePath();
        return image;
    }

    /**
     * 启动定位
     * 约定:Vue 调用 startLocation(callbackId)
     * 定位信息实时通过回调推送
     */
    @JavascriptInterface
    public void startLocation(String callbackId) {
        currentCallbackIdForLocation = callbackId;
        // 检查权限
        if (ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
            // 越界,需要在 Activity 中动态申请权限,这里仅简单提示
            Toast.makeText(activity, "缺少定位权限", Toast.LENGTH_SHORT).show();
            return;
        }
        // 注册监听
        try {
            locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER,
                    2000, 5, locationListener);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 停止定位
     * Vue 调用 stopLocation()
     */
    @JavascriptInterface
    public void stopLocation() {
        locationManager.removeUpdates(locationListener);
    }

    /** 定位监听 */
    private final LocationListener locationListener = new LocationListener() {
        @Override
        public void onLocationChanged(Location location) {
            try {
                JSONObject result = new JSONObject();
                result.put("latitude", location.getLatitude());
                result.put("longitude", location.getLongitude());
                result.put("accuracy", location.getAccuracy());
                // 实时回调给 JS
                callbackToJs(currentCallbackIdForLocation, result.toString());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        @Override public void onStatusChanged(String provider, int status, Bundle extras) {}
        @Override public void onProviderEnabled(String provider) {}
        @Override public void onProviderDisabled(String provider) {}
    };

    /**
     * 核心:向 JS 端回调的方法
     *  JavaScript 接收: window.onNativeCallback(callbackId, jsonString)
     */
    private void callbackToJs(String callbackId, String jsonString) {
        final String script = "window.onNativeCallback('" + callbackId + "', '" + jsonString.replace("'", "\\'") + "')";
        activity.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
                    webView.evaluateJavascript(script, null);
                } else {
                    webView.loadUrl("javascript:" + script);
                }
            }
        });
    }
}
  • 核心思路

    1. Vue 端调用 window.Android.openCamera(callbackId),Java 植入的 JSBridge.openCamera(String callbackId) 收到。
    2. openCamera 方法内启动相机 Intent,并将 callbackId 缓存到 currentCallbackIdForPhoto
    3. 拍照完成后,在 onActivityResult 中得到照片保存位置 currentPhotoPath,构造结果 JSON 并调用 callbackToJs(callbackId, resultJson)
    4. callbackToJs 方法底层通过 webView.evaluateJavascript("window.onNativeCallback('cb_123', '{...}')") 将消息推送到 JS。
    5. Vue 端全局注册了 window.onNativeCallback,它会根据 callbackIdcallbackMap 中取得对应的回调函数并执行。
  • 定位示例

    • Vue 端调用 window.Android.startLocation(callbackId),Java 中开始注册 LocationListener 并实时回调:每当有新定位时,就执行 callbackToJs(callbackId, resultJson),Vue 一旦收到就可以更新界面。
    • 如果要停止定位,可调用 window.Android.stopLocation(),Java 中会移除监听。

5.4 Android 调用 Vue 的方法(evaluateJavascript)

  1. Java 端调用 evaluateJavascript

    String script = "window.onNativeCallback('" + callbackId + "', '" + jsonString + "')";
    webView.post(() -> {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            webView.evaluateJavascript(script, null);
        } else {
            webView.loadUrl("javascript:" + script);
        }
    });
    • :需要在主线程调用 evaluateJavascript,因此用 webView.post(...) 确保在 UI 线程执行。
    • 对于 Android 4.4 以下可用 webView.loadUrl("javascript:...") 兼容。
  2. Vue 端接收回调

    • jsbridge.js 中定义了全局函数:

      window.onNativeCallback = function (callbackId, jsonResult) {
        // 这里把 jsonResult 反序列化,并根据 callbackId 找到对应回调
      };
    • 只要 Android 端执行了上述 JS,就会触发 Vue 端的回调分发逻辑。

综合示例:获取设备信息与拍照功能

下面整合上文思路,做一个完整可运行的示例:

效果预览

  • 点击「获取设备信息」,在页面上显示品牌、型号、系统版本;
  • 点击「拍照并获取照片」,调用摄像头拍摄,拍完后在页面上展示拍摄结果。

6.1 需求分析

  • Vue 端

    1. 页面有两个按钮:获取设备信息、拍照。
    2. 点击按钮时,通过封装的 JSBridge.callNativeWithCallback(...) 发起调用,并注册回调函数。
    3. 当设备信息回传后,页面更新对应 ref;当拍照成功后,页面把得到的本地路径展示并渲染 <img>
  • Android 端

    1. JSBridge 中实现 getDeviceInfo(callbackId)openCamera(callbackId) 两个方法。
    2. getDeviceInfo 直接读取 Build 信息并回传;
    3. openCamera 启动摄像头 Intent,保存到本地文件;
    4. onActivityResult 中获取结果并回调 Vue。

6.2 Android 端实现

  1. 确保 AndroidManifest.xml 已经声明摄像头和存储读写权限,以及配置 FileProvider

    <!-- AndroidManifest.xml -->
    
    <application ...>
        <!-- FileProvider 配置,用于 7.0+ 的文件 Uri 权限 -->
        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="${applicationId}.fileprovider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_paths" />
        </provider>
        ...
    </application>

    res/xml/file_paths.xml

    <?xml version="1.0" encoding="utf-8"?>
    <paths xmlns:android="http://schemas.android.com/apk/res/android">
        <!-- 将 /storage/emulated/0/Android/data/<package>/files/Pictures/ 映射给外部访问 -->
        <external-files-path name="my_images" path="Pictures/" />
    </paths>
  2. MainActivity.java 中添加 onActivityResult 转发给 JSBridge

    // MainActivity.java(补充部分)
    
    public class MainActivity extends AppCompatActivity {
    
        private JSBridge jsBridge;
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            ...
            jsBridge = new JSBridge(this, webView);
            webView.addJavascriptInterface(jsBridge, "Android");
            ...
        }
    
        @Override
        protected void onActivityResult(int requestCode, int resultCode, Intent data) {
            super.onActivityResult(requestCode, resultCode, data);
            // 转发给 JSBridge 处理拍照回调
            jsBridge.onActivityResult(requestCode, resultCode, data);
        }
    }
  3. JSBridge.java 代码如上文所示,重点是 getDeviceInfoopenCamera,以及构造回调。

    // 见上文 JSBridge.java

6.3 Vue 端实现

  1. 项目结构

    vue-android-bridge/
    ├─ public/
    |    └─ index.html
    ├─ src/
    |    ├─ main.js
    |    ├─ App.vue
    |    ├─ components/
    |    |    └─ NativeDemo.vue
    |    └─ utils/
    |         └─ jsbridge.js
    ├─ package.json
    └─ vite.config.js
  2. src/utils/jsbridge.js(与前文一致):

    /** jsbridge.js */
    function isAndroid() {
      return typeof window.Android !== 'undefined';
    }
    
    function callNative(method, ...args) {
      if (isAndroid() && typeof window.Android[method] === 'function') {
        try {
          const strArgs = args.map(arg => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg)));
          return window.Android[method](...strArgs);
        } catch (e) {
          console.error(`[JSBridge] 调用原生方法 ${method} 失败`, e);
        }
      } else {
        console.warn(`[JSBridge] 环境不支持 Android 原生调用:${method}`);
      }
    }
    
    const callbackMap = new Map();
    
    function genCallbackId() {
      return 'cb_' + Date.now() + '_' + Math.floor(Math.random() * 10000);
    }
    
    function callNativeWithCallback(method, args = [], callback) {
      const callbackId = genCallbackId();
      if (typeof callback === 'function') {
        callbackMap.set(callbackId, callback);
      }
      callNative(method, ...args, callbackId);
    }
    
    window.onNativeCallback = function (callbackId, jsonResult) {
      try {
        const result = JSON.parse(jsonResult);
        const cb = callbackMap.get(callbackId);
        if (cb) {
          cb(result);
          callbackMap.delete(callbackId);
        }
      } catch (e) {
        console.error('[JSBridge] 解析回调数据失败', e);
      }
    };
    
    export default {
      callNative,
      callNativeWithCallback
    };
  3. src/components/NativeDemo.vue(已在第 4.3 小节给出):

    <template>
      <div class="demo">
        <h2>Android 原生功能示例</h2>
    
        <div class="section">
          <button @click="fetchDeviceInfo">获取设备信息</button>
          <div v-if="deviceInfo">
            <p><strong>设备信息:</strong></p>
            <p>品牌:{{ deviceInfo.brand }}</p>
            <p>型号:{{ deviceInfo.model }}</p>
            <p>系统版本:{{ deviceInfo.osVersion }}</p>
          </div>
        </div>
    
        <hr />
    
        <div class="section">
          <button @click="takePhoto">拍照并获取照片</button>
          <div v-if="photoPath">
            <p><strong>照片路径:</strong> {{ photoPath }}</p>
            <img :src="photoUrl" alt="拍照结果" style="max-width: 300px; margin-top: 8px; border: 1px solid #ccc;" />
          </div>
        </div>
      </div>
    </template>
    
    <script setup>
    import { ref } from 'vue';
    import JSBridge from '@/utils/jsbridge';
    
    const deviceInfo = ref(null);
    const photoPath = ref('');
    const photoUrl = ref('');
    
    function fetchDeviceInfo() {
      JSBridge.callNativeWithCallback('getDeviceInfo', [], (result) => {
        deviceInfo.value = result;
      });
    }
    
    function takePhoto() {
      JSBridge.callNativeWithCallback('openCamera', [], (result) => {
        if (result.success) {
          photoPath.value = result.path;
          photoUrl.value = `file://${result.path}`;
        } else {
          alert('拍照失败:' + result.message);
        }
      });
    }
    </script>
    
    <style scoped>
    .demo {
      padding: 16px;
    }
    .section {
      margin-bottom: 24px;
    }
    button {
      padding: 8px 16px;
      background: #409eff;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }
    button:hover {
      background: #66b1ff;
    }
    </style>
  4. src/App.vue:仅引用 NativeDemo 以展示示例。

    <template>
      <div id="app">
        <NativeDemo />
      </div>
    </template>
    
    <script setup>
    import NativeDemo from './components/NativeDemo.vue';
    </script>
    
    <style>
    #app {
      font-family: Avenir, Helvetica, Arial, sans-serif;
    }
    </style>
  5. src/main.js:正常挂载。

    import { createApp } from 'vue';
    import App from './App.vue';
    
    const app = createApp(App);
    app.mount('#app');
  6. 打包并集成到 Android

    npm run build
    • 将生成的 dist/ 目录整体复制到 app/src/main/assets/www/,保持文件结构不变:

      app/
      └─ src/
         └─ main/
            └─ assets/
               └─ www/
                  ├─ index.html
                  ├─ assets/...
  7. 运行

    • 在 Android Studio 中运行应用。应用启动后会加载 file:///android_asset/www/index.html,Vue 页面显示。
    • 点击「获取设备信息」应能看到品牌、型号、系统版本;点击「拍照并获取照片」应打开相机,拍照完毕后页面显示图片。

6.4 数据流动图解

┌───────────────────────────────────────────────────────────┐
│                    用户点击“拍照”                         │
└───────────────────────────────────────────────────────────┘
                ↓ Vue 端
┌───────────────────────────────────────────────────────────┐
│ NativeDemo.vue → JSBridge.callNativeWithCallback("openCamera", [], cbId) │
│    → window.Android.openCamera(cbId)                           │
└───────────────────────────────────────────────────────────┘
                ↓ Android WebView
┌───────────────────────────────────────────────────────────┐
│ JSBridge.openCamera(cbId):构建拍照 Intent,并缓存 cbId   │
│    启动相机应用拍照,保存文件到本地路径 currentPhotoPath    │
└───────────────────────────────────────────────────────────┘
                ↓ 拍照完成后 onActivityResult
┌───────────────────────────────────────────────────────────┐
│ JSBridge.onActivityResult → sendPhotoResult(true, path)    │
│    → callbackToJs(cbId, JSON.stringify({ success:true, path })) │
│    → webView.evaluateJavascript("window.onNativeCallback(cbId,'{...}')") │
└───────────────────────────────────────────────────────────┘
                ↓ Vue 端
┌───────────────────────────────────────────────────────────┐
│ window.onNativeCallback(cbId, jsonResult) 被触发           │
│    → 在 jsbridge.js 中,找到 callbackMap.get(cbId),执行回调  │
│    → NativeDemo.vue 中注册的回调被调用, photoPath = result.path │
└───────────────────────────────────────────────────────────┘
                ↓ Vue 页面
┌───────────────────────────────────────────────────────────┐
│ <img :src="file:///.../xxx.jpg" /> 显示拍摄结果             │
└───────────────────────────────────────────────────────────┘

进阶示例:定位功能与实时回调

在综合示例基础上,再来看一个稍复杂一些的“实时定位”场景:

7.1 需求分析

  • 在页面上点击「开始定位」,调用 Android 原生 startLocation,并实时在 Vue 页面显示经纬度变化。
  • 点击「停止定位」,停止原生端的定位监听。
  • 定位权限需在 Android 端动态申请。

7.2 Android 端实现(Location + 权限)

  1. 确保权限

    • AndroidManifest.xml 中已声明:

      <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
      <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    • MainActivity.java 中动态申请(前文已有申请列表,可复用)。
  2. JSBridge.java 中定位相关方法

    // JSBridge.java 中定位相关内容(见第 5.3 节)
    @JavascriptInterface
    public void startLocation(String callbackId) {
        currentCallbackIdForLocation = callbackId;
        if (ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
            Toast.makeText(activity, "缺少定位权限", Toast.LENGTH_SHORT).show();
            return;
        }
        try {
            locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER,
                    2000, 5, locationListener);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
    @JavascriptInterface
    public void stopLocation() {
        locationManager.removeUpdates(locationListener);
    }
    
    private final LocationListener locationListener = new LocationListener() {
        @Override
        public void onLocationChanged(Location location) {
            try {
                JSONObject result = new JSONObject();
                result.put("latitude", location.getLatitude());
                result.put("longitude", location.getLongitude());
                result.put("accuracy", location.getAccuracy());
                callbackToJs(currentCallbackIdForLocation, result.toString());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        // 其他回调空实现
        @Override public void onStatusChanged(String provider, int status, Bundle extras) {}
        @Override public void onProviderEnabled(String provider) {}
        @Override public void onProviderDisabled(String provider) {}
    };
  3. MainActivity.java 动态申请定位权限(可参考第 5.2 段中的权限申请)

    • 若用户拒绝权限,需要在 JavaScript 层或页面上给出提示。

7.3 Vue 端实现与展示

  1. 新建组件:src/components/LocationDemo.vue

    <template>
      <div class="location-demo">
        <h2>Android 原生定位示例</h2>
        <div class="controls">
          <button @click="startLoc" :disabled="locating">开始定位</button>
          <button @click="stopLoc" :disabled="!locating">停止定位</button>
        </div>
        <div v-if="locating">
          <p>实时定位中...</p>
          <p>经度:{{ longitude }}</p>
          <p>纬度:{{ latitude }}</p>
          <p>精度:{{ accuracy }} 米</p>
        </div>
      </div>
    </template>
    
    <script setup>
    import { ref } from 'vue';
    import JSBridge from '@/utils/jsbridge';
    
    const locating = ref(false);
    const latitude = ref(0);
    const longitude = ref(0);
    const accuracy = ref(0);
    
    // 开始定位
    function startLoc() {
      locating.value = true;
      JSBridge.callNativeWithCallback('startLocation', [], (result) => {
        // result = { latitude: 39.9, longitude: 116.4, accuracy: 10.0 }
        latitude.value = result.latitude;
        longitude.value = result.longitude;
        accuracy.value = result.accuracy;
      });
    }
    
    // 停止定位
    function stopLoc() {
      locating.value = false;
      JSBridge.callNative('stopLocation');
    }
    </script>
    
    <style scoped>
    .location-demo {
      padding: 16px;
    }
    .controls {
      margin-bottom: 16px;
    }
    button {
      padding: 8px 16px;
      background: #67c23a;
      color: white;
      border: none;
      border-radius: 4px;
      margin-right: 8px;
      cursor: pointer;
    }
    button:disabled {
      background: #a0a0a0;
      cursor: not-allowed;
    }
    button:hover:not(:disabled) {
      background: #85ce61;
    }
    </style>
  2. App.vue 中引入并展示

    <template>
      <div id="app">
        <NativeDemo />
        <hr />
        <LocationDemo />
      </div>
    </template>
    
    <script setup>
    import NativeDemo from './components/NativeDemo.vue';
    import LocationDemo from './components/LocationDemo.vue';
    </script>
  3. 运行效果

    • 点击「开始定位」,如果 Android 端已获得定位权限,就会触发 locationListener.onLocationChanged,不断回调坐标到 JS,页面实时更新。
    • 点击「停止定位」,停止原生层的 requestLocationUpdates

7.4 通信流程图解

┌──────────────────────────┐
│ 用户点击“开始定位”按钮     │
└──────────────────────────┘
          ↓ Vue 端
┌────────────────────────────────────────┐
│ LocationDemo.vue → JSBridge.callNativeWithCallback(  │
│   'startLocation', [], cbId )                    │
│   → window.Android.startLocation(cbId)            │
└────────────────────────────────────────┘
          ↓ Android 端
┌────────────────────────────────────────┐
│ JSBridge.startLocation(cbId):检查权限 →  启动   │
│ locationManager.requestLocationUpdates(...)       │
└────────────────────────────────────────┘
          ↓ 设备定位变化,触发 onLocationChanged
┌────────────────────────────────────────┐
│ onLocationChanged(Location loc) → 构造 JSON        │
│ → callbackToJs(cbId, jsonString) →                     │
│ webView.evaluateJavascript("window.onNativeCallback(cbId,'{...}')") │
└────────────────────────────────────────┘
          ↓ Vue 端
┌────────────────────────────────────────┐
│ window.onNativeCallback(cbId, jsonResult)            │
│    → callbackMap.get(cbId)( result )                │
│    → 更新 latitude、longitude、accuracy               │
└────────────────────────────────────────┘
          ↓ 页面实时更新
┌──────────────────────────┐
│ 显示 最新 纬度/经度/精度   │
└──────────────────────────┘
          ↓ 用户点击“停止定位”
┌────────────────────────────────────────┐
│ LocationDemo.vue → JSBridge.callNative('stopLocation') │
│   → window.Android.stopLocation()                       │
│   → Android 端 locationManager.removeUpdates(...)           │
└────────────────────────────────────────┘

常见问题与调试方法

  1. WebView 不显示 JS 调用

    • 确认 webView.getSettings().setJavaScriptEnabled(true) 已经设置。
    • 确认注入对象的名称与 JS 侧调用一致:addJavascriptInterface(jsBridge, "Android") vs window.Android.method()
    • 如果页面打不开本地资源,检查 file:///android_asset/www/index.html 路径是否正确,并确认 assets 目录下已经放置好打包文件。
  2. @JavascriptInterface 方法未被调用或报错

    • @JavascriptInterface 只对 public 方法生效,需保证方法签名为 public void 方法名(String 参数)
    • 如果方法签名与 Vue 端传递不一致(参数个数/类型不匹配),会导致 JS 调用无响应。一般将所有参数都声明为 String,在方法内部再做 JSON.parse(...)new JSONObject(...)
    • 如果多参数情况,Vue 端需按顺序传入多个字符串,Android 方法签名必须与之对应。
  3. evaluateJavascript 无回调或抛异常

    • 确保在主线程中执行 webView.evaluateJavascript(...),可使用 runOnUiThread(...)
    • 对于 Android 4.3 以下版本,只能使用 webView.loadUrl("javascript:...")
    • 如果回调函数名称书写错误(与 Vue 端定义不一致),JS 侧不会执行。
  4. 图片路径无法显示

    • Android 7.0+ 需要使用 FileProvider 来获取 Uri,并且在 <img> 标签中以 src="file://..." 的方式展示。
    • 如果 <img> 不显示,检查文件是否真实存在、文件权限是否正确、以及路径是否加了 file:// 前缀。
  5. 定位无回调或坐标不准确

    • 确认 Android 端已动态申请并获得定位权限,否则 requestLocationUpdates 会直接抛异常或无回调。
    • 如果使用模拟器,需在模拟器设置中开启 GPS 模拟位置或在 Android Studio 的模拟器 Extended Controls → Location 中手动推送经纬度。
    • Android 10+ 对后台定位限制更严格,确保有 ACCESS_FINE_LOCATION 权限,以及必要时申请“后台定位”权限。
  6. 跨页面或多 WebView 通信混乱

    • 如果项目中有多个 ActivityFragment 都有 WebView,需为每个 WebView 单独注入不同的 JSBridge 对象,避免回调混淆。可在构造 JSBridge 时传入不同的 webView 实例。
    • Vue 端可为不同功能定义不同的回调 ID 前缀,方便区分。

总结与最佳实践

  1. 分离关注点

    • Vue 端仅关注业务逻辑(调用 JSBridge、更新 ref、渲染 UI),不直接操作 Android 原生 API。
    • Android 端仅关注原生功能(拍照、定位、传感器等),通过 @JavascriptInterface 方法对外暴露接口。
  2. 统一回调管理

    • jsbridge.js 中维护一个全局的 callbackMap,通过 callbackId 做双向映射,避免多次调用冲突。
    • 所有回调数据约定采用 JSON 串,保证跨平台兼容。
  3. 权限与生命周期管理

    • Android 端要及时申请并检查权限,必要时在 onRequestPermissionsResult 中判断权限是否被拒绝。
    • 对于需要生命周期控制的操作(如定位监听、传感器监听),在 Activity.onDestroy() 中做好清理,避免内存泄漏。
  4. 优化加载方式

    • 开发阶段:可以直接 webView.loadUrl("http://10.0.2.2:3000/") 进行热更新开发。
    • 生产阶段:将 Vue 构建产物拷贝到 assets/www/,以 file://android_asset/www/index.html 方式加载,减少网络依赖与加载延迟。
  5. 调试建议

    • 打开 WebView 调试:WebView.setWebContentsDebuggingEnabled(true),这样可在 Chrome DevTools 中远程调试 WebView 页面的 JS。
    • 在 Vue 端控制台加上适当的 console.log,在 Chrome DevTools 中可实时查看 JS 调用与回调日志。
    • 在 Android Studio Logcat 中过滤关键字(JSBridgeonNativeCallback),查看原生日志和回调过程。
  6. 安全与优化

    • addJavascriptInterface 会存在安全风险,一定要避免暴露敏感方法,且在 Android 4.2 以下可能存在反射漏洞。强烈建议应用最低 SDK 版本设为 17 及以上,并且注入的 JSBridge 方法仅提供最小必要功能。
    • 对于大型项目,可考虑使用成熟的混合框架(如 Capacitor、Weex、Flutter + Dart 插件)来管理更复杂的原生与 JS 通信,但对于小型项目或自研需求,上述方案已经足够稳定。

通过本文的完整示例原理剖析,你已经掌握了:

  • 如何在 Android 原生项目中集成 Vue(Vite)构建产物;
  • 如何在 WebView 中注入原生接口并实现双向通信;
  • 如何在 Vue 组件中调用原生方法并在回调中更新 UI;
  • 如何在 Android 端获取拍照结果、定位结果并实时推送给 JS。

希望这篇《Android 原生功能与 Vue 交互实现全攻略》能够帮助你在后续项目中快速搭建混合开发框架,轻松集成摄像头、定位、文件、传感器等各种原生能力,打造更加丰富的移动端应用体验!

# ‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌React Native 错误采集原理及 Android 平台实现详解

在移动应用开发中,**错误采集**(Error Reporting)能帮助我们在第一时间发现并定位线上问题,极大提升产品质量与用户体验。本文将从错​误采集的整体原理出发,结合 React Native 框架的特点,详细讲解如何在**Android 平台**实现完整的错误采集方案。文章包含架构原理、关键代码示例、ASCII 图解与详细说明,帮助你快速上手并构建自己的错误采集系统。

---

## 目录

1. [前言](#一-前言)  
2. [错误采集原理概览](#二-错误采集原理概览)  
   1. [JS 层错误捕获](#21-js-层错误捕获)  
   2. [Native 层错误捕获(Android)](#22-native-层错误捕获android)  
   3. [React Native 桥与异常传递](#23-react-native-桥与异常传递)  
3. [Android 平台实现详解](#三-android-平台实现详解)  
   1. [JavaScript 层面采集](#31-javascript-层面采集)  
      - [全局异常捕获:ErrorUtils](#311-全局异常捕获errorutils)  
      - [示例代码:捕获 JS 错误并上报](#312-示例代码捕获-js-错误并上报)  
   2. [Native(Java)层面采集](#32-nativejava-层面采集)  
      - [UncaughtExceptionHandler 介绍](#321-uncaughtexceptionhandler-介绍)  
      - [示例代码:在 Application 中设置全局捕获](#322-示例代码在-application-中设置全局捕获)  
   3. [JS 错误向 Native 传递](#33-js-错误向-native-传递)  
      - [使用 NativeModules 传递错误信息](#331-使用-nativemodules-传递错误信息)  
      - [示例代码:JS 调用 Native 上报接口](#332-示例代码js-调用-native-上报接口)  
   4. [错误存储与网络上报](#34-错误存储与网络上报)  
      - [本地存储方案:文件、SQLite 或 SharedPreferences](#341-本地存储方案文件sqlite-或-sharedpreferences)  
      - [网络上报方案:RESTful 接口调用](#342-网络上报方案restful-接口调用)  
      - [示例代码:保存本地并异步上报](#343-示例代码保存本地并异步上报)  
4. [错误上报流程图解](#四-错误上报流程图解)  
5. [集成示例:自定义错误采集库](#五-集成示例自定义错误采集库)  
   1. [代码结构](#51-代码结构)  
   2. [主要功能模块说明](#52-主要功能模块说明)  
   3. [完整 Demo](#53-完整-demo)  
6. [常见问题与最佳实践](#六-常见问题与最佳实践)  
7. [总结](#七-总结)  

---

## 一、前言

React Native 混合了 JavaScript 与原生代码,既有 JS 引擎执行的逻辑错误,也可能因原生模块或第三方库引发的崩溃(Crash)。线上应用若无法及时捕获并上报这些错误,就很难定位问题根源、快速迭代。  

- **JS 层错误**:诸如 `undefined is not an object`、Promise 未捕获的异常、UI 组件渲染出错等,均会在 JS 引擎中抛出异常。  
- **Native 层错误**(Android):Java/Kotlin 抛出的 `NullPointerException`、`IndexOutOfBoundsException`、甚至由于 NDK 引发的 native crash,都需要在原生层进行捕获。  

React Native 提供了 JS 与 Native 互通的“桥”(Bridge)机制,我们可以将 JS 层捕获到的异常传递到 Native,再由 Native 统一进行存储与上报。接下来,本文先从原理层面概述捕获流程,然后深入 Android 平台实现细节。

---

## 二、错误采集原理概览

在 React Native 中,错误采集通常分为两个阶段:  
1. **捕获阶段**:捕获 JS 及 Native 层抛出的异常;  
2. **上报阶段**:将异常信息持久化并发送到服务器,用于后续分析。

主要原理如下:

┌────────────────────────────────────────────────────────────────┐
│ React Native 应用 │
│ ┌───────────┐ ┌────────────┐ ┌───────────────┐ │
│ │ JS 层 │──捕获──▶│ 错误处理 │──Native─▶│ 错误上传组件 │ │
│ │ (ErrorUtils)│ │ (Native Module)│ │ (Retrofit) │ │
│ └───────────┘ └────────────┘ └───────────────┘ │
│ ▲ ▲ │
│ │ │ │
│ 错误抛出 (TypeError, etc.) 错误抛出 (NPE, etc.) │
└────────────────────────────────────────────────────────────────┘


### 2.1 JS 层错误捕获

- 利用 React Native 内置的 [`ErrorUtils`](https://reactnative.dev/docs/javascript-environment#errorutils) 全局对象,拦截未捕获的 JS 异常。  
- 也可在组件中使用 `try/catch` 捕获同步 / 异步异常,或重写 `console.error`、`window.onerror` 来捕获。  
- 捕获后,将关键信息(错误消息、堆栈、设备信息、应用版本号等)封装后,调用 Native 模块进行上报或持久化。  

### 2.2 Native 层错误捕获(Android)

- **Java 异常**:在 `Application` 或某个 `Activity` 中通过 `Thread.setDefaultUncaughtExceptionHandler(...)` 设​置全局的 `UncaughtExceptionHandler`,捕获所有未处理的 Java 异常。  
- **NDK 异常**(Native Crash):若涉及 native 代码,可借助如 [NDK Crash Handler](https://source.android.com/devices/tech/debug) 或第三方库(如 Breakpad、Bugly NDK)进行捕获。  
- 捕获到异常后,同样将信息(`Throwable` 堆栈、设备信息)传入错误采集模块,统一处理。  

### 2.3 React Native 桥与异常传递

- React Native 的桥(Bridge)允许 JS 与 Native 互相调用。JS 捕获到异常后,通过 `NativeModules.ErrorReportModule.sendJSException(...)` 将错误信息传递到 Android Native 端;  
- 对于 Native 层捕获的异常,可直接在 `UncaughtExceptionHandler` 中调用网络请求或存储逻辑;也可以通过 RN 的 `DevSupportManager` 触发 RN 的红屏(仅开发模式)。  
- 最终,所有异常信息都会汇总到同一个“错误采集中心”进行存储(本地缓存)和网络上报。  

---

## 三、Android 平台实现详解

下面我们重点围绕 Android 平台,分层次详细讲解如何捕获并上报 React Native 中的各种异常。

### 3.1 JavaScript 层面采集

#### 3.1.1 全局异常捕获:ErrorUtils

React Native 在 JS 环境中提供了一个全局对象 `ErrorUtils`,可以用来替换默认的错误处理器,从而捕获所有未被 `try/catch` 包裹的异常。典型用法如下:

```js
// src/jsExceptionHandler.js

import { NativeModules } from 'react-native';
const { ErrorReportModule } = NativeModules;

/**
 * 自定义 JS 全局异常处理器
 * @param {Error} error 捕获到的 Error 对象
 * @param {boolean} isFatal 表示是否为致命异常(RN 默认认为部分异常会触发红屏)
 */
function globalErrorHandler(error, isFatal) {
  // 1. 格式化错误信息
  const errorMessage = error.message;
  const stackTrace = error.stack; // 多行堆栈信息

  // 2. 构建上报参数
  const errorInfo = {
    message: errorMessage,
    stack: stackTrace,
    isFatal,
    ts: Date.now(),
    // 可加入更多业务字段,如 React 版本、App 版本、用户 ID 等
  };

  // 3. 调用 Native 模块上报到 Android 端
  ErrorReportModule.sendJSException(JSON.stringify(errorInfo));

  // 4. 若是开发模式,可调用默认处理以显示红屏提示;生产环境可静默处理
  if (__DEV__) {
    // 如果想保留 RN 红屏,可调用默认处理器
    // ErrorUtils.getGlobalHandler()(error, isFatal);
    console.warn('开发环境下,调用默认红屏处理');
  } else {
    // 生产环境:静默或展示自定义错误页面
    console.log('生产环境下,已将错误上报,建议重启应用或跳转到安全页面');
  }
}

// 注册全局异常处理器
ErrorUtils.setGlobalHandler(globalErrorHandler);

关键说明:

  1. ErrorUtils.setGlobalHandler(handler)

    • 此方法用于替换 React Native 默认的全局错误处理器,将所有未被 try/catch 捕获的异常交给 handler 处理。
    • handler 接收两个参数:error(Error 对象)和 isFatal(布尔值)。其中 isFatal = true 时,React Native 默认会显示红屏并终止 JS 执行;可以根据业务决定是否调用默认处理器。
  2. error.stack

    • 包含了多行堆栈信息,包括文件名、行号和函数名,有助于精确定位问题。
  3. 上报到 Native

    • 通过 NativeModules.ErrorReportModule.sendJSException(...) 将错误信息传到 Android 端,后续由 Native 统一存储与上报。
  4. 生产/开发环境差异

    • 在开发模式(__DEV__ === true)下,通常保留默认红屏提示以便调试;在生产模式下可选择静默或展示自定义错误页面。

3.1.2 示例代码:捕获 JS 错误并上报

在应用入口(如 index.jsApp.js)中,需在最早阶段安装全局异常处理器:

// index.js

import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';
import './jsExceptionHandler'; // 引入全局异常处理模块

AppRegistry.registerComponent(appName, () => App);

此时,任何 JS 运行期间抛出的未捕获异常都会被 globalErrorHandler 捕获,并立即调用 Native 方法进行上报。


3.2 Native(Java)层面采集

在 Android 平台,除了 JS 层可能发生的错误,还需要在 Native 层捕获 Java 层或 NDK 层抛出的异常。

3.2.1 UncaughtExceptionHandler 介绍

Java 提供了 Thread.setDefaultUncaughtExceptionHandler(...) 接口,用于设置全局未捕获异常处理器。典型流程如下:

  1. 在自定义的 Application 子类中实现 Thread.UncaughtExceptionHandler 接口。
  2. onCreate() 方法中,通过 Thread.setDefaultUncaughtExceptionHandler(...) 注册该处理器。
  3. 当任何未捕获的 Java 异常(如 NullPointerException)抛出时,系统会调用我们的 uncaughtException(Thread t, Throwable e) 方法。
  4. uncaughtException 中,可进行日志收集、设备信息采集,并通过网络上报或写入本地文件;也可选择重启应用或直接杀进程。

3.2.2 示例代码:在 Application 中设置全局捕获

// android/app/src/main/java/com/myapp/MyApplication.java

package com.myapp;

import android.app.Application;
import android.content.Context;
import android.os.Looper;
import android.os.Handler;
import android.util.Log;

import androidx.annotation.NonNull;

import org.json.JSONObject;

import java.io.File;
import java.io.FileWriter;
import java.io.PrintWriter;

import okhttp3.*; // 使用 OkHttp 进行网络上报

public class MyApplication extends Application implements Thread.UncaughtExceptionHandler {

    private static final String TAG = "CrashHandler";
    private Thread.UncaughtExceptionHandler defaultHandler;

    @Override
    public void onCreate() {
        super.onCreate();

        // 1. 记录系统默认的异常处理器
        defaultHandler = Thread.getDefaultUncaughtExceptionHandler();

        // 2. 设置当前 CrashHandler 为默认处理器
        Thread.setDefaultUncaughtExceptionHandler(this);
    }

    /**
     * 全局未捕获异常处理
     *
     * @param thread 抛出异常的线程
     * @param ex     Throwable 对象
     */
    @Override
    public void uncaughtException(@NonNull Thread thread, @NonNull Throwable ex) {
        // 1. 将异常信息写入本地文件
        writeExceptionToFile(ex);

        // 2. 异步上报到服务器
        postExceptionToServer(ex);

        // 3. 延迟一段时间后杀进程或调用默认处理器
        new Handler(Looper.getMainLooper()).postDelayed(() -> {
            // 若希望保留默认系统弹窗,可调用:
            // defaultHandler.uncaughtException(thread, ex);

            // 否则直接杀死进程
            android.os.Process.killProcess(android.os.Process.myPid());
            System.exit(1);
        }, 2000);
    }

    /**
     * 将 Throwable 信息写入本地文件
     */
    private void writeExceptionToFile(Throwable ex) {
        try {
            File dir = new File(getFilesDir(), "crash_logs");
            if (!dir.exists()) dir.mkdirs();
            String fileName = "crash_" + System.currentTimeMillis() + ".log";
            File logFile = new File(dir, fileName);

            FileWriter fw = new FileWriter(logFile);
            PrintWriter pw = new PrintWriter(fw);
            ex.printStackTrace(pw);
            pw.close();
            fw.close();

            Log.d(TAG, "Exception written to file: " + logFile.getAbsolutePath());
        } catch (Exception e) {
            Log.e(TAG, "Failed to write exception file", e);
        }
    }

    /**
     * 异步上报到服务器
     */
    private void postExceptionToServer(Throwable ex) {
        new Thread(() -> {
            try {
                // 1. 构建 JSON payload
                JSONObject json = new JSONObject();
                json.put("timestamp", System.currentTimeMillis());
                json.put("exception", ex.toString());
                json.put("stack", getStackString(ex));
                json.put("appVersion", "1.0.0");
                json.put("deviceModel", android.os.Build.MODEL);
                // 可根据业务需求添加更多字段

                // 2. 使用 OkHttp 发送 POST 请求
                OkHttpClient client = new OkHttpClient();
                RequestBody body = RequestBody.create(
                    json.toString(),
                    MediaType.parse("application/json; charset=utf-8")
                );
                Request request = new Request.Builder()
                    .url("https://api.example.com/reportCrash")
                    .post(body)
                    .build();

                Response response = client.newCall(request).execute();
                if (response.isSuccessful()) {
                    Log.d(TAG, "Crash report sent successfully");
                } else {
                    Log.e(TAG, "Crash report failed: " + response.code());
                }
            } catch (Exception e) {
                Log.e(TAG, "Error posting exception to server", e);
            }
        }).start();
    }

    /**
     * 获取 Throwable 的堆栈字符串
     */
    private String getStackString(Throwable ex) {
        StringBuilder sb = new StringBuilder();
        for (StackTraceElement element : ex.getStackTrace()) {
            sb.append(element.toString()).append("\n");
        }
        return sb.toString();
    }
}

关键说明:

  1. 记录并调用默认处理器

    • onCreate() 中,使用 Thread.getDefaultUncaughtExceptionHandler() 获取系统默认的异常处理器,并在自定义捕获完成后,可选择调用默认处理器以展示系统弹窗。
  2. 本地写日志

    • 将异常堆栈写入应用私有目录下的 crash_logs 文件夹中,文件名包含时间戳便于后续查找。
  3. 异步上报

    • 利用 OkHttp 在新线程中以 JSON 形式 POST 到后端 REST 接口。
    • 上报内容通常包含:时间戳、异常类名与消息、堆栈信息、App 版本、设备信息、系统版本、网络状态等。
  4. 延迟退出

    • 在上报完成或等待一定时间后,可选择杀死进程(避免应用处于不稳定状态)。如果想保留“原生 Crash 弹窗”,可调用 defaultHandler.uncaughtException(thread, ex)

3.3 JS 错误向 Native 传递

很多时候我们更关心的是 JS 端的业务逻辑错误,因此需要将 JS 捕获到的异常传递到 Native 层进行统一处理或持久化。

3.3.1 使用 NativeModules 传递错误信息

在 Android 端,需要先创建一个原生模块 ErrorReportModule,暴露给 JS 调用:

// android/app/src/main/java/com/myapp/ErrorReportModule.java

package com.myapp;

import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;

import android.util.Log;

import org.json.JSONObject;

/**
 * ErrorReportModule 用于接收 JS 端传过来的异常信息,并进行本地保存或上报
 */
public class ErrorReportModule extends ReactContextBaseJavaModule {
    private static final String TAG = "ErrorReportModule";

    public ErrorReportModule(ReactApplicationContext reactContext) {
        super(reactContext);
    }

    @Override
    public String getName() {
        return "ErrorReportModule";
    }

    /**
     * JS 端调用该方法上报异常
     *
     * @param jsonStr 包含异常信息的 JSON 字符串
     * @param promise 回调 Promise,用于通知 JS 端是否成功
     */
    @ReactMethod
    public void sendJSException(String jsonStr, Promise promise) {
        try {
            // 1. 解析 JSON
            JSONObject json = new JSONObject(jsonStr);
            String message = json.optString("message");
            String stack = json.optString("stack");
            boolean isFatal = json.optBoolean("isFatal", false);
            long ts = json.optLong("ts");

            // 2. 将异常信息写到本地文件或数据库
            writeJSErrorToFile(message, stack, isFatal, ts);

            // 3. 异步上报到服务器(可与 Java Crash 上报合并接口)
            postJSErrorToServer(json);

            // 成功后返回
            promise.resolve(true);
        } catch (Exception e) {
            Log.e(TAG, "Failed to send JS exception", e);
            promise.reject("ErrorReportFail", e);
        }
    }

    private void writeJSErrorToFile(String message, String stack, boolean isFatal, long ts) {
        // 参考 Java Crash 写文件逻辑,将 JS 错误写入独立目录
        // e.g., getReactApplicationContext().getFilesDir() + "/js_error_logs/"
    }

    private void postJSErrorToServer(JSONObject json) {
        // 直接复用上文 Java Crash 的 postExceptionToServer 方法
        // 或者在这里再构建一个 HTTP 请求上报 JS 错误
    }
}

MainApplication.java 中注册该模块:

// android/app/src/main/java/com/myapp/MainApplication.java

import com.myapp.ErrorReportModule; // 引入

@Override
protected List<ReactPackage> getPackages() {
    return Arrays.<ReactPackage>asList(
        new MainReactPackage(),
        new ErrorReportPackage() // 自定义 package,返回 ErrorReportModule
    );
}

然后创建 ErrorReportPackage

// android/app/src/main/java/com/myapp/ErrorReportPackage.java

package com.myapp;

import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;

public class ErrorReportPackage implements ReactPackage {
    @Override
    public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
        return Arrays.<NativeModule>asList(new ErrorReportModule(reactContext));
    }

    @Override
    public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
        return Collections.emptyList();
    }
}

3.3.2 示例代码:JS 调用 Native 上报接口

在前文注册了 ErrorUtils.setGlobalHandlerglobalErrorHandler 中,我们只需调用:

import { NativeModules } from 'react-native';
const { ErrorReportModule } = NativeModules;

// 假设已在 globalErrorHandler 中调用
ErrorReportModule.sendJSException(JSON.stringify(errorInfo))
  .then(() => console.log('JS exception reported successfully'))
  .catch((err) => console.error('Failed to report JS exception', err));

当 JS 端捕获到错误时,会将 errorInfo 以 JSON 字符串形式传给 Native,再由 Native 统一写文件或上报。


3.4 错误存储与网络上报

3.4.1 本地存储方案:文件、SQLite 或 SharedPreferences

  • 文件方案

    • 最简单也是最常用的方式:将异常日志写入应用私有目录(getFilesDir())下的 crash_logs/js_error_logs/ 文件夹,每次写一个新文件,文件名可包含时间戳,示例如:

      /data/data/com.myapp/files/crash_logs/crash_1625078400000.log
      /data/data/com.myapp/files/js_error_logs/js_error_1625078400000.log
    • 优点:实现简单、易区分;缺点:文件数量多时需定期清理,可自行在写入时检查旧日志并删除超过一定条数或时间的文件。
  • SQLite 方案

    • 若需要复杂查询或聚合分析,可借助 SQLite 在本地维护一个 errors 表,字段包括:idtimestamptype(js/native)messagestackdeviceInfosentStatus(是否已上报) 等。
    • 优点:可灵活根据条件查询;缺点:实现较文件方案复杂、性能稍低(写入大量日志需注意批量插入优化)。
  • SharedPreferences 方案

    • 一般只适用于保存少量最后一次错误信息,可用于应用重启后显示上次崩溃原因,但不适合长期存储大量日志。

3.4.2 网络上报方案:RESTful 接口调用

  • 统一上报接口

    • 后端可以提供一个 POST /api/v1/reportError 接口,接收 JSON 格式错误信息,包括:

      {
        "type": "js" | "native",
        "timestamp": 1625078400000,
        "message": "TypeError: undefined is not an object",
        "stack": "...",
        "deviceModel": "Pixel 5",
        "osVersion": "Android 11",
        "appVersion": "1.0.0",
        "network": "WIFI",
        "userId": "12345"
      }
    • Android 使用 OkHttp 或 Retrofit 进行异步 POST;iOS 可用 Alamofire;JS 可用 fetch()
  • 批量上报

    • 当网络恢复时(监听网络变化),可一次性将本地缓存中的多条日志批量上报,以减少网络请求次数并保证严格的“至少一次”上报语义。
  • 失败重试与幂等

    • 若上报失败(如网络中断),可保存到本地并在下一次网络可达时重试;后端可根据设备 ID + 时间戳做幂等去重。

3.4.3 示例代码:保存本地并异步上报

ErrorReportModule.sendJSException 中,我们可以先将 JSON 字符串写入本地文件,再调用一个统一的 uploadPendingLogs() 方法,将所有未上报的日志文件逐个发送至服务器并删除:

// android/app/src/main/java/com/myapp/ErrorReportModule.java

private void writeJSErrorToFile(String message, String stack, boolean isFatal, long ts) {
    try {
        File dir = new File(getReactApplicationContext().getFilesDir(), "js_error_logs");
        if (!dir.exists()) dir.mkdirs();
        String fileName = "js_" + ts + ".log";
        File logFile = new File(dir, fileName);

        FileWriter fw = new FileWriter(logFile);
        PrintWriter pw = new PrintWriter(fw);
        pw.println("timestamp:" + ts);
        pw.println("isFatal:" + isFatal);
        pw.println("message:" + message);
        pw.println("stack:");
        pw.println(stack);
        pw.close();
        fw.close();
        Log.d(TAG, "JS Exception written to file: " + logFile.getAbsolutePath());

        // 保存完文件后,尝试上报所有待上传日志
        uploadPendingLogs("js_error_logs");
    } catch (Exception e) {
        Log.e(TAG, "Failed to write JS exception file", e);
    }
}

private void uploadPendingLogs(String subDir) {
    new Thread(() -> {
        try {
            File dir = new File(getReactApplicationContext().getFilesDir(), subDir);
            if (!dir.exists() || !dir.isDirectory()) return;
            File[] files = dir.listFiles();
            if (files == null || files.length == 0) return;

            OkHttpClient client = new OkHttpClient();
            for (File logFile : files) {
                // 1. 读取文件内容
                StringBuilder sb = new StringBuilder();
                java.io.BufferedReader br = new java.io.BufferedReader(new java.io.FileReader(logFile));
                String line;
                while ((line = br.readLine()) != null) {
                    sb.append(line).append("\n");
                }
                br.close();

                // 2. 构建 JSON 对象
                JSONObject payload = new JSONObject();
                payload.put("type", subDir.startsWith("js") ? "js" : "native");
                payload.put("log", sb.toString());
                // 可加入额外字段:App 版本、设备信息等

                // 3. 发送 POST 请求
                RequestBody body = RequestBody.create(
                    payload.toString(),
                    MediaType.parse("application/json; charset=utf-8")
                );
                Request request = new Request.Builder()
                    .url("https://api.example.com/reportError")
                    .post(body)
                    .build();

                Response response = client.newCall(request).execute();
                if (response.isSuccessful()) {
                    // 删除已成功上报的文件
                    logFile.delete();
                    Log.d(TAG, "Uploaded and deleted log file: " + logFile.getName());
                } else {
                    Log.e(TAG, "Upload failed for file: " + logFile.getName() + ", code: " + response.code());
                }
            }
        } catch (Exception e) {
            Log.e(TAG, "Error uploading pending logs", e);
        }
    }).start();
}

四、错误上报流程图解

下面用 ASCII 图示展示从 JS 抛出异常到 Android 层捕获并上报的完整流程:

┌───────────────────────────────────────────────────────────────────┐
│                           React Native JS                        │
│  ┌───────────────────────────────────────────────────────────────┐│
│  │        1. 代码执行抛出未捕获异常 (e.g., TypeError)             ││
│  │  ┌─────────────────────────────────────────────────────────┐  ││
│  │  │ ErrorUtils.setGlobalHandler 捕获 (globalErrorHandler)   │  ││
│  │  │ - 格式化错误信息 (message, stack, ts, isFatal)          │  ││
│  │  │ - 调用: ErrorReportModule.sendJSException(jsonString)   │─┐│
│  │  └─────────────────────────────────────────────────────────┘  ││
│  │              ▲                                                ││
│  │              │  (Bridge: JS → Native)                          ││
│  └──────────────┴─────────────────────────────────────────────────┘│
│              │                                                       │
│              ▼                                                       │
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │                    Android Native (ErrorReportModule)         │  │
│  │  ┌──────────────────────────────────────────────────────────┐  │  │
│  │  │ sendJSException(jsonString)                             │  │  │
│  │  │ - 解析 JSON                                              │  │  │
│  │  │ - writeJSErrorToFile 写入 /files/js_error_logs/          │  │  │
│  │  │ - uploadPendingLogs 上传到 https://.../reportError     │  │  │
│  │  └──────────────────────────────────────────────────────────┘  │  │
│  │            ▲                                                  │  │
│  │            │ (异步上报,如成功则删文件; 失败则留待下次重试)     │  │
│  └────────────┴──────────────────────────────────────────────────┘  │
│              │                                                       │
│              ▼                                                       │
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │  2. Native 层 (Java CrashHandler) 捕获 Java 未捕获异常           │  │
│  │  Thread.setDefaultUncaughtExceptionHandler 监听 NPE, etc.       │  │
│  │  ┌──────────────────────────────────────────────────────────┐  │  │
│  │  │ uncaughtException(Thread t, Throwable ex)                │  │  │
│  │  │ - writeExceptionToFile 写 /files/crash_logs/              │  │  │
│  │  │ - postExceptionToServer 上传到 https://.../reportError   │  │  │
│  │  └──────────────────────────────────────────────────────────┘  │  │
│  │                  ▲                                            │  │
│  │                  │ (同样采用异步网络上报并删除已上报文件)   │  │
│  └──────────────────┴────────────────────────────────────────────┘  │
└───────────────────────────────────────────────────────────────────┘
  • JS 层:通过 ErrorUtils.setGlobalHandler 捕获未处理的 JS 异常,并调用 Native 模块上报。
  • Bridge:React Native 桥负责将 sendJSException 调用转给 Android 原生 ErrorReportModule
  • Native 层 JS 上报ErrorReportModule 将信息写入 /files/js_error_logs/,并尝试上传到服务器。
  • Native 层 Java Crash:通过 Thread.setDefaultUncaughtExceptionHandler 捕获所有 Java 未捕获异常,同样写入 /files/crash_logs/ 并异步上传。

五、集成示例:自定义错误采集库

下面我们以一个完整的自定义错误采集库为示例,演示如何将上述各模块结合起来,快速集成到 React Native 项目中。

5.1 代码结构

myapp/
├── android/
│   ├── app/
│   │   ├── src/
│   │   │   ├── main/
│   │   │   │   ├── java/com/myapp/
│   │   │   │   │   ├── ErrorReportModule.java
│   │   │   │   │   ├── ErrorReportPackage.java
│   │   │   │   │   ├── MyApplication.java
│   │   │   │   │   └── CrashHandler.java
│   │   │   └── ...
│   │   └── AndroidManifest.xml
│   └── build.gradle
├── src/
│   ├── jsExceptionHandler.js   // JS 全局异常捕获
│   └── App.js
├── index.js                    // 应用入口
└── package.json
  • ErrorReportModule.java:负责接收 JS 异常并存储/上报。
  • CrashHandler.java:实现 Thread.UncaughtExceptionHandler,负责捕获 Java 异常。
  • MyApplication.java:在 Application 中注册 CrashHandler,并导入 ErrorReportModule
  • jsExceptionHandler.js:安装 ErrorUtils 全局异常处理。
  • App.js / index.js:应用入口,加载全局异常处理器并启动主界面。

5.2 主要功能模块说明

  1. JS 全局错误捕获 (jsExceptionHandler.js)

    • 使用 ErrorUtils.setGlobalHandler 捕获所有 JS 未捕获异常,调用 NativeModules.ErrorReportModule.sendJSException(...)
  2. 原生模块 ErrorReportModule (ErrorReportModule.java)

    • 暴露 sendJSException(String jsonStr, Promise promise) 方法给 JS。
    • 将接收到的 jsonStr 写入本地文件夹 js_error_logs/,并调用统一上报接口。
  3. Java Crash 捕获 (CrashHandler.java)

    • 实现 Thread.UncaughtExceptionHandler,在 uncaughtException(Thread t, Throwable ex) 中将异常写入 crash_logs/,并上报。
  4. 应用生命周期与注册 (MyApplication.java)

    • onCreate() 中注册 Thread.setDefaultUncaughtExceptionHandler(new CrashHandler(...)),并向 React Native 注册 ErrorReportModule
  5. 异步上报逻辑

    • 使用 OkHttp 在新线程里将所有待上报的日志文件逐条发送至后端 REST 接口,并在成功后删除对应文件。

5.3 完整 Demo

下文给出各模块的完整代码示例,帮助你快速复制到自己的项目中使用。

5.3.1 CrashHandler.java

// android/app/src/main/java/com/myapp/CrashHandler.java

package com.myapp;

import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;

import org.json.JSONObject;

import java.io.File;
import java.io.FileWriter;
import java.io.PrintWriter;

import okhttp3.*;

public class CrashHandler implements Thread.UncaughtExceptionHandler {
    private static final String TAG = "CrashHandler";
    private Context mContext;
    private Thread.UncaughtExceptionHandler defaultHandler;

    public CrashHandler(Context context) {
        mContext = context;
        defaultHandler = Thread.getDefaultUncaughtExceptionHandler();
    }

    @Override
    public void uncaughtException(Thread t, Throwable ex) {
        // 写入本地
        writeExceptionToFile(ex);

        // 上报到服务器
        postExceptionToServer(ex);

        // 延迟退出
        new Handler(Looper.getMainLooper()).postDelayed(() -> {
            // 可调用默认处理器(系统弹窗),或直接杀进程
            // defaultHandler.uncaughtException(t, ex);
            android.os.Process.killProcess(android.os.Process.myPid());
            System.exit(1);
        }, 2000);
    }

    private void writeExceptionToFile(Throwable ex) {
        try {
            File dir = new File(mContext.getFilesDir(), "crash_logs");
            if (!dir.exists()) dir.mkdirs();
            String fileName = "native_" + System.currentTimeMillis() + ".log";
            File logFile = new File(dir, fileName);

            FileWriter fw = new FileWriter(logFile);
            PrintWriter pw = new PrintWriter(fw);
            ex.printStackTrace(pw);
            pw.close();
            fw.close();
            Log.d(TAG, "Native exception written to: " + logFile.getAbsolutePath());
        } catch (Exception e) {
            Log.e(TAG, "Failed to write native exception", e);
        }
    }

    private void postExceptionToServer(Throwable ex) {
        new Thread(() -> {
            try {
                JSONObject json = new JSONObject();
                json.put("type", "native");
                json.put("timestamp", System.currentTimeMillis());
                json.put("message", ex.toString());
                json.put("stack", getStackString(ex));
                json.put("appVersion", "1.0.0");
                json.put("deviceModel", android.os.Build.MODEL);

                OkHttpClient client = new OkHttpClient();
                RequestBody body = RequestBody.create(
                    json.toString(),
                    MediaType.parse("application/json; charset=utf-8")
                );
                Request request = new Request.Builder()
                    .url("https://api.example.com/reportError")
                    .post(body)
                    .build();

                Response response = client.newCall(request).execute();
                if (response.isSuccessful()) {
                    Log.d(TAG, "Native crash report sent");
                    // 可根据业务删除本地文件
                } else {
                    Log.e(TAG, "Native crash report failed: " + response.code());
                }
            } catch (Exception e) {
                Log.e(TAG, "Error sending native crash to server", e);
            }
        }).start();
    }

    private String getStackString(Throwable ex) {
        StringBuilder sb = new StringBuilder();
        for (StackTraceElement element : ex.getStackTrace()) {
            sb.append(element.toString()).append("\n");
        }
        return sb.toString();
    }
}

5.3.2 ErrorReportModule.java

// android/app/src/main/java/com/myapp/ErrorReportModule.java

package com.myapp;

import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;

import android.util.Log;

import org.json.JSONObject;

import java.io.File;
import java.io.FileWriter;
import java.io.PrintWriter;

import okhttp3.*;

public class ErrorReportModule extends ReactContextBaseJavaModule {
    private static final String TAG = "ErrorReportModule";

    public ErrorReportModule(ReactApplicationContext reactContext) {
        super(reactContext);
    }

    @Override
    public String getName() {
        return "ErrorReportModule";
    }

    @ReactMethod
    public void sendJSException(String jsonStr, Promise promise) {
        try {
            JSONObject json = new JSONObject(jsonStr);
            String message = json.optString("message");
            String stack = json.optString("stack");
            boolean isFatal = json.optBoolean("isFatal", false);
            long ts = json.optLong("ts");

            writeJSErrorToFile(message, stack, isFatal, ts);
            uploadPendingLogs("js_error_logs");

            promise.resolve(true);
        } catch (Exception e) {
            Log.e(TAG, "Failed to send JS exception", e);
            promise.reject("ErrorReportFail", e);
        }
    }

    private void writeJSErrorToFile(String message, String stack, boolean isFatal, long ts) {
        try {
            File dir = new File(getReactApplicationContext().getFilesDir(), "js_error_logs");
            if (!dir.exists()) dir.mkdirs();
            String fileName = "js_" + ts + ".log";
            File logFile = new File(dir, fileName);

            FileWriter fw = new FileWriter(logFile);
            PrintWriter pw = new PrintWriter(fw);
            pw.println("timestamp:" + ts);
            pw.println("isFatal:" + isFatal);
            pw.println("message:" + message);
            pw.println("stack:");
            pw.println(stack);
            pw.close();
            fw.close();
            Log.d(TAG, "JS exception written to: " + logFile.getAbsolutePath());
        } catch (Exception e) {
            Log.e(TAG, "Failed to write JS exception file", e);
        }
    }

    private void uploadPendingLogs(String subDir) {
        new Thread(() -> {
            try {
                File dir = new File(getReactApplicationContext().getFilesDir(), subDir);
                if (!dir.exists() || !dir.isDirectory()) return;
                File[] files = dir.listFiles();
                if (files == null || files.length == 0) return;

                OkHttpClient client = new OkHttpClient();
                for (File logFile : files) {
                    StringBuilder sb = new StringBuilder();
                    java.io.BufferedReader br = new java.io.BufferedReader(new java.io.FileReader(logFile));
                    String line;
                    while ((line = br.readLine()) != null) {
                        sb.append(line).append("\n");
                    }
                    br.close();

                    JSONObject payload = new JSONObject();
                    payload.put("type", "js");
                    payload.put("log", sb.toString());
                    payload.put("appVersion", "1.0.0");
                    payload.put("deviceModel", android.os.Build.MODEL);

                    RequestBody body = RequestBody.create(
                        payload.toString(),
                        MediaType.parse("application/json; charset=utf-8")
                    );
                    Request request = new Request.Builder()
                        .url("https://api.example.com/reportError")
                        .post(body)
                        .build();

                    Response response = client.newCall(request).execute();
                    if (response.isSuccessful()) {
                        logFile.delete();
                        Log.d(TAG, "Uploaded and deleted JS log: " + logFile.getName());
                    } else {
                        Log.e(TAG, "Upload failed for JS log: " + logFile.getName());
                    }
                }
            } catch (Exception e) {
                Log.e(TAG, "Error uploading pending JS logs", e);
            }
        }).start();
    }
}

5.3.3 MyApplication.java

// android/app/src/main/java/com/myapp/MyApplication.java

package com.myapp;

import android.app.Application;
import android.util.Log;

import com.facebook.react.ReactApplication;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
import com.facebook.react.shell.MainReactPackage;

import java.util.Arrays;
import java.util.List;

public class MyApplication extends Application implements ReactApplication {
    private static final String TAG = "MyApplication";

    private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
        @Override
        public boolean getUseDeveloperSupport() {
            return BuildConfig.DEBUG;
        }

        @Override
        protected List<ReactPackage> getPackages() {
            return Arrays.<ReactPackage>asList(
                new MainReactPackage(),
                new ErrorReportPackage()
            );
        }

        @Override
        protected String getJSMainModuleName() {
            return "index";
        }
    };

    @Override
    public void onCreate() {
        super.onCreate();

        // 注册 Java 全局异常捕获
        Thread.setDefaultUncaughtExceptionHandler(new CrashHandler(this));
        Log.d(TAG, "CrashHandler registered");
    }

    @Override
    public ReactNativeHost getReactNativeHost() {
        return mReactNativeHost;
    }
}

5.3.4 jsExceptionHandler.js

// src/jsExceptionHandler.js

import { NativeModules } from 'react-native';
const { ErrorReportModule } = NativeModules;

/**
 * 全局 JS 错误处理器
 */
function globalErrorHandler(error, isFatal) {
  const errorMessage = error.message;
  const stackTrace = error.stack;
  const errorInfo = {
    message: errorMessage,
    stack: stackTrace,
    isFatal,
    ts: Date.now(),
  };

  ErrorReportModule.sendJSException(JSON.stringify(errorInfo))
    .then(() => console.log('JS exception reported'))
    .catch((err) => console.error('Failed to report JS exception', err));

  if (__DEV__) {
    // 保留红屏提示
    console.warn('开发模式:调用默认红屏处理');
    ErrorUtils.getGlobalHandler()(error, isFatal);
  } else {
    // 生产模式:静默处理或显示自定义页面
    console.log('生产模式:JS 错误已上报,建议重启应用');
  }
}

// 安装全局错误处理器
ErrorUtils.setGlobalHandler(globalErrorHandler);

5.3.5 App.jsindex.js

// App.js

import React from 'react';
import { View, Text, Button, StyleSheet } from 'react-native';

export default function App() {
  // 故意抛出一个未捕获异常用于测试
  const throwError = () => {
    // 下面这一行将触发 JS 错误
    const a = undefined;
    console.log(a.b.c);
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>React Native 错误采集 Demo</Text>
      <Button title="触发 JS 错误" onPress={throwError} />
      <Button
        title="触发本地 Crash"
        onPress={() => {
          throw new Error('模拟本地 Crash'); // 可触发 Native Java Crash
        }}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 16 },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 24 },
});
// index.js

import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';
import './src/jsExceptionHandler'; // 引入全局异常捕获模块

AppRegistry.registerComponent(appName, () => App);

至此,一个完整的React Native 错误采集库已集成完毕:

  • JS 层:未捕获异常会触发 globalErrorHandler,调用 ErrorReportModule.sendJSException
  • Native(Java)层:未捕获的 Java Exception 会触发 CrashHandler.uncaughtException,写文件并上报。
  • 所有日志先被写入本地文件,再通过异步线程逐条上传后删除,保证“至少一次”上报。

六、常见问题与最佳实践

  1. JS 异常捕获不到

    • 确认是否已在入口文件(index.js)最早阶段就引入了 jsExceptionHandler.js
    • 避免使用第三方框架(如 Redux-Saga)导致的异步错误没有抛出到全局。可在每个 saga 中添加 .catch()
  2. Native 层异常 handler 被覆盖

    • 某些第三方库(如 Crashlytics)会在 Application.onCreate() 中设置自己的 UncaughtExceptionHandler,导致我们的 Handler 无效。
    • 解决办法:在 MyApplication.onCreate() 中先获取系统默认 Handler,再将 Crashlytics 的 Handler 包裹或链式调用。
  3. 上报接口频繁失败

    • 当网络不可用时,上报会失败。建议在失败时保留本地日志,监听网络恢复后再重试。
    • 使用 OkHttp 的拦截器或 WorkManager 进行持久化重试。
  4. 日志文件过多导致存储空间不足

    • 定期(如应用启动时)扫描并删除超过一定时长(比如 7 天)的旧日志文件。
    • 或在每次写入时检查存储总量是否超限(如 10MB),若超则删除最早的若干文件。
  5. NDK 层 Crash 如何捕获

    • NDK Crash 需要使用 native Crash 处理库,如 Google BreakpadTencent Bugly NDK
    • 这些库会在本地生成 .apk_crash.so_crash 日志,再配合 Java 上传逻辑上报。

七、总结

本文系统性地介绍了 React Native 中的错误采集原理与 Android 平台的实现细节,主要包括:

  1. JS 层错误捕获:通过 ErrorUtils.setGlobalHandler 拦截所有未捕获的 JS 异常,并借助 NativeModules 将信息传递到 Android 端。
  2. Native 层错误捕获:使用 Thread.setDefaultUncaughtExceptionHandler 捕获 Java 未捕获异常,并写入本地文件和网络上报。
  3. 异步上报与本地存储:示例代码展示了如何将日志写入私有目录,并使用 OkHttp 在后台线程中将所有待上报日志逐条发送到服务器。
  4. 完整 Demo:整合各模块,提供一个可直接复制粘贴到项目中的错误采集库示例。
  5. 流程图解与最佳实践:帮助大家快速理解从 JS 到 Native 再到服务器的错误上报链路,以及实际落地时的注意事项。

通过本文,你应当能够在自己的 React Native 项目中快速集成错误采集功能,实时监控线上异常,并通过日志聚合与分析提升产品可靠性。