使用 React + Capacitor 构建 Android 混合应用外壳:集成扫码、定位与 NFC 功能实战
PS:本文介绍如何使用前端 React 开发 Android 外壳应用,以及如何在页面中调用 Android 硬件功能,以扫码、定位、NFC 为例。
React 构建 Android 外壳
一、创建项目
npmcreate vite@latest二、添加依赖
1、核心插件 CapacitorJS
2. 安装插件
npmi @capacitor/corenpmi-D@capacitor/cli3、初始化 Capacitor
npx cap init运行后会创建capacitor.config.json文件,该文件记录了项目构建的输出目录(webDir),通常对应 Angular 项目的 www、React 项目的 build、Vue 项目的 public 等目录。
{"appId":"com.sggk.dongte.warehouse.app","appName":"XXXApp安装后显示的名称","webDir":"dist"}三、创建 Android 项目
安装 Capacitor 核心运行时后,即可添加 Android 平台支持。
npmi @capacitor/android# 创建 Android 项目npx capaddandroid运行后,会在项目根目录生成一个android文件夹,其中包含了转换后的 Android 项目代码。
同步代码
创建本地项目后,您可以通过运行以下命令将 Web 应用程序同步到本地项目。
npx capsync[^]:npx cap sync会将您构建的 Web 包(默认位于 Capacitor 配置文件的webDir目录中)复制到您的本地项目,并安装本地项目的依赖项。如果修改了 Capacitor 配置文件,也会同步过去。也就是说,项目初始化后,后续任何配置或代码的更改,都只需运行npx cap sync命令来同步。
将同步命令添加到package.json的scripts中,之后运行npm run build:cap命令即可构建并同步代码到 Android 项目。
"build:cap":"vite build && npx cap sync",四、添加扫码、定位、NFC相关依赖
# 按需安装你使用的插件npminstall@capacitor/barcode-scanner# 扫码npminstall@capacitor/geolocation# 定位npminstall@capgo/capacitor-nfc# NFCnpminstall@capacitor/app# 首页或登录页面返回# 安装 sg-capacitor-bridge 插件Capacitor 插件的 iframe + postMessage 桥接库。用于 Android 包装应用通过 iframe 加载远程网页应用时,让网页应用可以调用父窗口的 Capacitor 原生插件。npmi git+https://gitee.com/cc_nbplus/android-ifream-calls-hardware.git五、前端页面(Android 外壳)
1、capacitor.config.ts 配置
importtype{CapacitorConfig}from'@capacitor/cli';constconfig:CapacitorConfig={appId:'com.xx.xx.app',// 安卓包名appName:'xxx 程序安装后桌面显示的名称',webDir:'dist',plugins:{CapacitorHttp:{enabled:true// 跨域}},server:{androidScheme:'http'// 保证 HTTPS 请求成功}};exportdefaultconfig;2、路由 routers
import { createHashRouter, Navigate } from "react-router"; import Home from "../pages/Home"; import BackButtonGuard from "../components/BackButtonGuard"; const router = createHashRouter([ { element: <BackButtonGuard />, // 用于首页或登录页再次返回退出程序 children: [ {"path": "/home", element: <Home/>}, {"path": "/", element: <Navigate to={"/home"}/>} ] } ]) export default router;3. App.tsx
import {RouterProvider} from "react-router"; import routers from "@/routers"; function App() { return <RouterProvider router={routers} /> } export default App4.Home.tsx
import { useEffect, useRef } from 'react' import { createBridgeHost, builtins } from 'sg-capacitor-bridge' import { CapacitorBarcodeScanner } from '@capacitor/barcode-scanner' const REMOTE_URL = 'http://xxxx' // 服务器上的应用程序网页地址 export default function Home() { const iframeRef = useRef<HTMLIFrameElement>(null) useEffect(() => { const host = createBridgeHost({ getIframe: () => iframeRef.current, }) // 注册扫码插件 host.use(builtins.barcodeScanner.setup(CapacitorBarcodeScanner)) // 需要什么插件,直接注册 // host.use(builtins.xxx.setup(xxx)) return () => host.destroy() }, []) return ( <iframe ref={iframeRef} src={REMOTE_URL} style={{ position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', border: 'none', margin: 0, padding: 0, }} /> ) }5. 首页或登录页再次返回退出程序
hooks/useBackButtonHandler.ts
import{useEffect,useRef}from"react";import{App,typeBackButtonListenerEvent}from"@capacitor/app";import{useLocation,useNavigate}from"react-router";/** * 用于处理 Capacitor 应用中硬件返回按钮的 React Hook。 * 此优化版本仅注册一次监听器,并使用 Capacitor 内置的 `canGoBack` 状态。 * @param exitPaths 返回按钮应触发应用退出确认的路径数组。 * 根据路由配置,此数组应包含 '/login' 和 '/home'。 */exportconstuseBackButtonHandler=(exitPaths=["/login","/home"])=>{constlocation=useLocation();constnavigate=useNavigate();// 使用 useRef 存储最新的 location 和 navigate,以避免在 useEffect 依赖中包含它们,// 从而防止监听器在每次路由变化时都被重新注册。constlastState=useRef({location,navigate,exitPaths,});// 每次渲染时都更新 ref 中的最新状态useEffect(()=>{lastState.current={location,navigate,exitPaths};});// 这个 useEffect 只在组件挂载时运行一次,负责注册和清理监听器useEffect(()=>{// 定义核心处理逻辑consthandleBackButton=async(event:BackButtonListenerEvent)=>{// 从 ref 中获取最新的状态,确保逻辑总是使用当前的数据const{location:currentLocation,navigate:currentNavigate,exitPaths:currentExitPaths,}=lastState.current;constisExitPath=currentExitPaths.includes(currentLocation.pathname);// 场景合并:// 1. 如果当前页面是指定的退出页 (isExitPath)// 2. 或者,如果 Capacitor 确认已经没有可回退的 WebView 历史 (!event.canGoBack)// 这两种情况下,都应该提示用户退出。if(isExitPath||!event.canGoBack){// 使用 window.confirm 是一个简单的方式,在实际项目中你可能想用一个自定义的UI组件if(window.confirm("确定要退出应用吗?")){awaitApp.exitApp();}}else{// 其他所有情况,执行标准的返回操作currentNavigate(-1);}};// 注册监听器。App.addListener 返回一个 Promise,解析后得到监听器实例constlistenerPromise=App.addListener("backButton",handleBackButton);// 组件卸载时,确保移除监听器return()=>{listenerPromise.then((listener)=>listener.remove());};},[]);// 空依赖数组 [] 保证这个 effect 只运行一次};components/BackButtonGuard.tsx
import { Outlet } from 'react-router' import { useBackButtonHandler } from '@/hooks/useBackButtonHandler.ts' export default function BackButtonGuard() { useBackButtonHandler(['/login', '/home']) return <Outlet /> }package.json 参考
"scripts":{"dev":"vite","build":"tsc -b && vite build","build:cap":"vite build && npx cap sync","lint":"eslint .","preview":"vite preview"},"dependencies":{"@capacitor/android":"^8.4.1","@capacitor/app":"^8.1.0","@capacitor/barcode-scanner":"^3.0.2","@capacitor/core":"^8.4.1","@capacitor/geolocation":"^8.2.0","@capgo/capacitor-nfc":"^8.1.5","react":"^19.1.1","react-dom":"^19.1.1","react-router":"^8.0.1","react-router-dom":"^7.18.0","sg-capacitor-bridge":"git+http:xxxx.git"},"devDependencies":{"@capacitor/cli":"^8.4.1","@eslint/js":"^9.36.0","@types/react":"^19.1.13","@types/react-dom":"^19.1.9","@vitejs/plugin-react":"^5.0.3","eslint":"^9.36.0","eslint-plugin-react-hooks":"^5.2.0","eslint-plugin-react-refresh":"^0.4.20","globals":"^16.4.0","typescript":"~5.8.3","typescript-eslint":"^8.44.0","vite":"^7.1.7"}六、Android 外壳开发结束,打包
使用 Android Studio 开发工具的 Gradle 打包
遇到问题
不能访问 http 资源
Android 项目默认只能访问 https 资源,解决方案可参考解决 Android 28 不能请求 HTTP 接口的问题 #5
本项目的解决方案如下:
- 新建
android/app/src/main/res/xml/network_security_config.xml文件
<?xml version="1.0" encoding="utf-8"?><network-security-config><base-configcleartextTrafficPermitted="true"/></network-security-config>- 在
app/src/main/AndroidManifest.xml中引入
<application...android:networkSecurityConfig="@xml/network_security_config"...>某些 Android 设备无法直接调用相机,且未获得相应权限
解决方法:在 AndroidManifest.xml 中添加以下信息
<uses-permissionandroid:name="android.permission.CAMERA"/><uses-permissionandroid:name="android.permission.READ_MEDIA_IMAGES"/><uses-featureandroid:name="android.hardware.camera"android:required="false"/>AndroidManifest.xml 参考示例
<?xml version="1.0" encoding="utf-8"?><manifestxmlns:android="http://schemas.android.com/apk/res/android"><applicationandroid:allowBackup="true"android:icon="@mipmap/ic_launcher"android:label="@string/app_name"android:roundIcon="@mipmap/ic_launcher_round"android:networkSecurityConfig="@xml/network_security_config"android:supportsRtl="true"android:theme="@style/AppTheme"><activityandroid:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"android:name=".MainActivity"android:label="@string/title_activity_main"android:theme="@style/AppTheme.NoActionBarLaunch"android:launchMode="singleTask"android:exported="true"><intent-filter><actionandroid:name="android.intent.action.MAIN"/><categoryandroid:name="android.intent.category.LAUNCHER"/></intent-filter></activity><providerandroid:name="androidx.core.content.FileProvider"android:authorities="${applicationId}.fileprovider"android:exported="false"android:grantUriPermissions="true"><meta-dataandroid:name="android.support.FILE_PROVIDER_PATHS"android:resource="@xml/file_paths"></meta-data></provider></application><!-- Permissions --><uses-permissionandroid:name="android.permission.INTERNET"/><uses-permissionandroid:name="android.permission.CAMERA"/><uses-permissionandroid:name="android.permission.READ_MEDIA_IMAGES"/><uses-featureandroid:name="android.hardware.camera"android:required="false"/></manifest>