使用 React + Capacitor 构建 Android 混合应用外壳:集成扫码、定位与 NFC 功能实战

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.jsonscripts 中,之后运行 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

本项目的解决方案如下:

  1. 新建 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>
  1. 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>

综上所述,使用外壳(容器)的好处在于:打包为 APK 后,用户仅需安装一次。后续更新时,除非更换应用页面的映射地址,否则用户无需重新安装应用,只需退出并重新进入程序即可。由于未获得苹果的许可,本文仅以Android平台为例进行说明。