PS:本文介绍如何使用前端 React 开发 Android 外壳应用,以及如何在页面中调用 Android 硬件功能,以扫码、定位、NFC 为例。
React 构建 Android 外壳
一、创建项目
sh
npm create vite@latest
二、添加依赖
1、核心插件 CapacitorJS
2. 安装插件
sh
npm i @capacitor/core
npm i -D @capacitor/cli
3、初始化 Capacitor
sh
npx cap init
运行后会创建 capacitor.config.json 文件,该文件记录了项目构建的输出目录(webDir),通常对应 Angular 项目的 www、React 项目的 build、Vue 项目的 public 等目录。
json
{
"appId": "com.sggk.dongte.warehouse.app",
"appName": "XXXApp安装后显示的名称",
"webDir": "dist"
}
三、创建 Android 项目
安装 Capacitor 核心运行时后,即可添加 Android 平台支持。
sh
npm i @capacitor/android
# 创建 Android 项目
npx cap add android
运行后,会在项目根目录生成一个 android 文件夹,其中包含了转换后的 Android 项目代码。
同步代码
创建本地项目后,您可以通过运行以下命令将 Web 应用程序同步到本地项目。
sh
npx cap sync
\^: npx cap sync 会将您构建的 Web 包(默认位于 Capacitor 配置文件的 webDir 目录中)复制到您的本地项目,并安装本地项目的依赖项。如果修改了 Capacitor 配置文件,也会同步过去。也就是说,项目初始化后,后续任何配置或代码的更改,都只需运行 npx cap sync 命令来同步。
将同步命令添加到 package.json 的 scripts 中,之后运行 npm run build:cap 命令即可构建并同步代码到 Android 项目。
json
"build:cap": "vite build && npx cap sync",
四、添加扫码、定位、NFC相关依赖
bash
# 按需安装你使用的插件
npm install @capacitor/barcode-scanner # 扫码
npm install @capacitor/geolocation # 定位
npm install @capgo/capacitor-nfc # NFC
npm install @capacitor/app # 首页或登录页面返回
# 安装 sg-capacitor-bridge 插件
Capacitor 插件的 iframe + postMessage 桥接库。用于 Android 包装应用通过 iframe 加载远程网页应用时,让网页应用可以调用父窗口的 Capacitor 原生插件。
npm i git+https://gitee.com/cc_nbplus/android-ifream-calls-hardware.git
五、前端页面(Android 外壳)
1、capacitor.config.ts 配置
typescript
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.xx.xx.app', // 安卓包名
appName: 'xxx 程序安装后桌面显示的名称',
webDir: 'dist',
plugins: {
CapacitorHttp: {
enabled: true // 跨域
}
},
server: {
androidScheme: 'http' // 保证 HTTPS 请求成功
}
};
export default config;
2、路由 routers
tsx
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
tsx
import {RouterProvider} from "react-router";
import routers from "@/routers";
function App() {
return <RouterProvider router={routers} />
}
export default App
4.Home.tsx
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
typescript
import { useEffect, useRef } from "react";
import { App, type BackButtonListenerEvent } from "@capacitor/app";
import { useLocation, useNavigate } from "react-router";
/**
* 用于处理 Capacitor 应用中硬件返回按钮的 React Hook。
* 此优化版本仅注册一次监听器,并使用 Capacitor 内置的 `canGoBack` 状态。
* @param exitPaths 返回按钮应触发应用退出确认的路径数组。
* 根据路由配置,此数组应包含 '/login' 和 '/home'。
*/
export const useBackButtonHandler = (exitPaths = ["/login", "/home"]) => {
const location = useLocation();
const navigate = useNavigate();
// 使用 useRef 存储最新的 location 和 navigate,以避免在 useEffect 依赖中包含它们,
// 从而防止监听器在每次路由变化时都被重新注册。
const lastState = useRef({
location,
navigate,
exitPaths,
});
// 每次渲染时都更新 ref 中的最新状态
useEffect(() => {
lastState.current = { location, navigate, exitPaths };
});
// 这个 useEffect 只在组件挂载时运行一次,负责注册和清理监听器
useEffect(() => {
// 定义核心处理逻辑
const handleBackButton = async (event: BackButtonListenerEvent) => {
// 从 ref 中获取最新的状态,确保逻辑总是使用当前的数据
const {
location: currentLocation,
navigate: currentNavigate,
exitPaths: currentExitPaths,
} = lastState.current;
const isExitPath = currentExitPaths.includes(currentLocation.pathname);
// 场景合并:
// 1. 如果当前页面是指定的退出页 (isExitPath)
// 2. 或者,如果 Capacitor 确认已经没有可回退的 WebView 历史 (!event.canGoBack)
// 这两种情况下,都应该提示用户退出。
if (isExitPath || !event.canGoBack) {
// 使用 window.confirm 是一个简单的方式,在实际项目中你可能想用一个自定义的UI组件
if (window.confirm("确定要退出应用吗?")) {
await App.exitApp();
}
} else {
// 其他所有情况,执行标准的返回操作
currentNavigate(-1);
}
};
// 注册监听器。App.addListener 返回一个 Promise,解析后得到监听器实例
const listenerPromise = App.addListener("backButton", handleBackButton);
// 组件卸载时,确保移除监听器
return () => {
listenerPromise.then((listener) => listener.remove());
};
}, []); // 空依赖数组 [] 保证这个 effect 只运行一次
};
components/BackButtonGuard.tsx
tsx
import { Outlet } from 'react-router'
import { useBackButtonHandler } from '@/hooks/useBackButtonHandler.ts'
export default function BackButtonGuard() {
useBackButtonHandler(['/login', '/home'])
return <Outlet />
}
package.json 参考
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
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
- 在
app/src/main/AndroidManifest.xml中引入
xml
<application
...
android:networkSecurityConfig="@xml/network_security_config"
...>
某些 Android 设备无法直接调用相机,且未获得相应权限
解决方法:在 AndroidManifest.xml 中添加以下信息
xml
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
AndroidManifest.xml 参考示例
xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android: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">
<activity
android: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>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<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"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
</manifest>