React Native for OpenHarmony 三方库集成实战:现场工具

验证日期: 2026-09-26
受测宿主:RN能力库 0.3.1

一、应用背景

现场巡检常需要三类轻量能力:开始操作时给出触感反馈,短时间打开手电筒照亮设备铭牌,读取媒体音量判断提示音是否可能被用户听见。这些能力的资源边界不同:触感是一次性动作,手电筒占用摄像头硬件,音量读取只能观察状态,不能偷偷修改用户设置。

因此,页面流程应当是“触感确认 -> 请求照明权限 -> 打开手电筒 -> 完成检查后关闭 -> 读取音量”。手电筒无论流程成功、异常还是页面退出,都必须进入关闭路径。

在这里插入图片描述

图 1:到场检查完成后,界面显示触感已确认、照明已关闭和当前媒体音量。

二、应用目标

  • 使用触感反馈确认用户已经开始现场操作;
  • 将摄像头权限拒绝、设备没有闪光灯和开关失败分别反馈;
  • 关闭照明后再读取媒体音量,读取过程不改变系统音量;
  • 防止重复点击和页面退出留下手电筒常亮。

三、三方库与版本

三方库锁定版本适配 TAG职责
expo-haptics57.0.357.0.3-ohos-1.0.0触感确认
react-native-torch1.2.01.2.0-ohos-1.0.0摄像头权限和手电筒开关
react-native-volume-control1.0.11.0.1-ohos-1.0.0读取归一化媒体音量

三个包均为 MIT 许可证的 OpenHarmony 交付包,版本、仓库和 TAG 截止 2026-09-26 核对。运行时为 React Native 0.84.1、RNOH 0.84.3、OpenHarmony 7.0.0.105。固定版本安装后执行:

npm install
./node_modules/.bin/react-native link-harmony
cd harmony && ohpm install --all && cd ..
npm run bundle:harmony

四、应用方式

现场检查函数用 try/finally 保证手电筒关闭;getVolume() 返回 0..1 的归一化值,只在展示层转成百分比:

import * as Haptics from 'expo-haptics';
import Torch from 'react-native-torch';
import VolumeControl from 'react-native-volume-control';

function wait(milliseconds: number) {
  return new Promise(resolve => setTimeout(resolve, milliseconds));
}

export async function runFieldCheck() {
  await Haptics.notificationAsync(
    Haptics.NotificationFeedbackType.Success,
  );

  const permission = await Torch.requestCameraPermission(
    '现场工具',
    '用于执行短时照明检查',
  );
  if (!permission) {
    return {status: 'permission-denied' as const};
  }

  let torchAttempted = false;
  try {
    torchAttempted = true;
    await Torch.switchState(true);
    await wait(650);
    const volume = await VolumeControl.getVolume();
    return {
      status: 'ok' as const,
      torchClosed: true,
      volumePercent: Math.round(volume * 100),
    };
  } finally {
    if (torchAttempted) {
      await Torch.switchState(false).catch(() => undefined);
    }
  }
}

如果 requestCameraPermission 的实现版本返回的不是布尔值,应以实际类型定义为准,并把“拒绝”和“设备不支持”映射成不同的业务状态。不要在 finally 外另外写一条正常分支关闭逻辑,否则异常分支很容易漏掉。

五、工程实现

用控制器串行化硬件操作。

手电筒是单一硬件资源。用户连续点击“打开”和“关闭”时,后发的意图不应被先发的慢 Promise 覆盖。控制器保存 desiredState,每个操作结束后重新收敛到最后一次意图:

type TorchState = 'off' | 'turningOn' | 'on' | 'turningOff' | 'error';

export class TorchController {
  private state: TorchState = 'off';
  private desired: 'on' | 'off' = 'off';
  private sequence = 0;

  getState() {
    return this.state;
  }

  async setDesired(next: 'on' | 'off') {
    this.desired = next;
    const sequence = ++this.sequence;
    this.state = next === 'on' ? 'turningOn' : 'turningOff';
    try {
      await Torch.switchState(next === 'on');
      if (sequence !== this.sequence) return;
      this.state = next;
      if (this.desired !== next) await this.setDesired(this.desired);
    } catch (error) {
      if (sequence === this.sequence) this.state = 'error';
      throw error;
    }
  }

  async dispose() {
    this.desired = 'off';
    ++this.sequence;
    try {
      await Torch.switchState(false);
      this.state = 'off';
    } catch (error) {
      this.state = 'error';
      throw error;
    }
  }
}

页面失焦、应用进入后台、用户取消任务和组件卸载都调用 dispose()。它不是取消原生 Promise,而是保证硬件最终收到关闭请求。

只读音量并校验范围。

音量值应在读取层保留浮点数,显示层才转成整数百分比。系统服务异常或返回 NaN 时,不能把它当成 0%:

type VolumeResult =
  | {status: 'ok'; value: number; percent: number}
  | {status: 'unavailable'; reason: string};

export async function readMediaVolume(): Promise<VolumeResult> {
  try {
    const value = await VolumeControl.getVolume();
    if (!Number.isFinite(value) || value < 0 || value > 1) {
      return {status: 'unavailable', reason: 'invalid-volume'};
    }
    return {status: 'ok', value, percent: Math.round(value * 100)};
  } catch {
    return {status: 'unavailable', reason: 'volume-service-unavailable'};
  }
}

本场景不调用 VolumeControl.change()。如果其他功能确实需要修改音量,必须记录原值,在流程结束或取消时恢复,并再次调用 getVolume() 核对恢复结果。

手电筒请求还要设置超时。系统权限弹窗、摄像头资源争用或设备从后台恢复时,Promise 可能很久才有结果;页面不能无限保持“开启中”:

function withTimeout<T>(task: Promise<T>, timeoutMs: number): Promise<T> {
  return Promise.race([
    task,
    new Promise<T>((_, reject) => {
      setTimeout(() => reject(new Error('torch-timeout')), timeoutMs);
    }),
  ]);
}

async function switchTorchSafely(enabled: boolean) {
  try {
    await withTimeout(Torch.switchState(enabled), 2_000);
    return {status: 'ok' as const};
  } catch (error) {
    return {
      status: 'error' as const,
      code: error instanceof Error ? error.message : 'torch-error',
    };
  }
}

超时只结束业务等待,不一定取消底层调用,所以超时后仍要执行一次关闭,并在下一次获得焦点时读取或探测真实状态。setTimeout 计时器应在控制器销毁时清理,避免页面离开后回调再次修改状态。

把权限和硬件能力分成两条状态。 permission 可以是 unknown、granted、denied,hardware 可以是 available、unavailable、busy。权限已授予但设备没有闪光灯时,不能提示用户重复授权;硬件忙时,可以稍后重试:

type FieldToolStatus = {
  permission: 'unknown' | 'granted' | 'denied';
  hardware: 'available' | 'unavailable' | 'busy';
  torch: 'off' | 'on' | 'error';
  volume: number | null;
};

应用进入后台时将 desired 设置为 off,返回前台时不自动打开,必须由用户重新触发。这样可以避免后台误占用摄像头,也能让系统隐私指示和页面状态保持一致。

现场任务还需要为每次操作建立上下文,避免上一次巡检的状态被下一次任务复用。上下文包括设备或工单 ID、开始时间、是否已经申请权限和手电筒开关尝试次数:

type FieldOperation = {
  operationId: string;
  taskId: string;
  startedAt: number;
  permission: 'unknown' | 'granted' | 'denied';
  torchAttempted: boolean;
  volume: number | null;
};

function createFieldOperation(taskId: string): FieldOperation {
  return {
    operationId: `${taskId}-${Date.now()}`,
    taskId,
    startedAt: Date.now(),
    permission: 'unknown',
    torchAttempted: false,
    volume: null,
  };
}

用户看到的提示应由状态决定:权限拒绝提供授权入口;没有闪光灯允许跳过;硬件忙提示稍后重试;音量服务失败则显示“音量不可读取”,不能把失败值显示为 0%。状态变化写入操作日志时只记录 operationId、错误码和耗时,不记录摄像头帧或现场照片。

控制器的 dispose 需要在测试中验证幂等性。重复调用不应发出相互冲突的开关序列,关闭失败也不应阻止后续一次重试:

it('makes dispose idempotent', async () => {
  const switchState = jest.spyOn(Torch, 'switchState').mockResolvedValue();
  const controller = new TorchController();

  await controller.dispose();
  await controller.dispose();

  expect(switchState).toHaveBeenCalledWith(false);
  expect(controller.getState()).toBe('off');
});

如果项目还支持扫码、相机预览或录像,手电筒控制器必须与这些功能共享硬件占用协调器。单个库的 switchState(false) 不能假设整个应用没有其他功能正在使用摄像头;协调器应按资源所有者维护引用计数或明确的互斥锁。

照明检查最好把固定等待改成可观察条件。示例中的 650ms 只是为了让演示页面留出观察时间,正式流程可以等待手电筒状态回调或独立探针确认亮度达到阈值;如果库没有状态事件,则将固定时长限制在很短的 UI 反馈范围,并把硬件释放放在 finally 中。不要因为等待时间变长就把手电筒当成后台资源。

如果现场任务还要播放提示音,提示音的音量读取应在手电筒关闭后进行,并在页面上说明当前音量只用于判断可听性。音量不是扬声器健康度的证明,设备静音、蓝牙路由和系统勿扰模式都可能导致实际听感不同,必要时增加一次用户确认而不是自动判定现场通过。

现场页面可以将三个能力的结果分别展示,避免一项失败覆盖其他成功结果:

function FieldToolSummary({result}: {result: FieldToolStatus}) {
  return (
    <View>
      <Text accessibilityLabel="触感反馈">触感:已发送</Text>
      <Text accessibilityLabel="手电筒状态">
        手电筒:{result.torch === 'on' ? '开启' : result.torch === 'off' ? '关闭' : '异常'}
      </Text>
      <Text accessibilityLabel="摄像头权限">
        权限:{result.permission === 'granted' ? '已授权' : '未授权'}
      </Text>
      <Text accessibilityLabel="媒体音量">
        音量:{result.volume === null ? '不可读取' : `${Math.round(result.volume * 100)}%`}
      </Text>
    </View>
  );
}

当权限被拒绝时,照明行显示授权操作,音量行仍可以独立读取;当音量服务不可用时,手电筒关闭结果仍然可以作为巡检结论。业务提交应携带每项状态,而不是只携带一个全局 success 布尔值。

故障矩阵至少包含:无闪光灯、首次授权拒绝、授权后系统回收、手电筒打开超时、音量 Promise 拒绝、应用进入后台和用户快速点击关闭。每个用例都要记录“最终手电筒状态”,这是硬件资源类功能比普通 Toast 更重要的断言。

清理逻辑最好独立成无 UI 依赖的函数,便于在页面卸载、后台切换和异常捕获中复用:

async function releaseFieldResources(controller: TorchController) {
  const results = await Promise.allSettled([
    controller.dispose(),
    readMediaVolume(),
  ]);
  return {
    torch: results[0].status === 'fulfilled' ? 'off' : 'unknown',
    volume: results[1].status === 'fulfilled' ? results[1].value : null,
  } as const;
}

这里的音量读取只作为结束记录,不修改音量;如果读取本身失败,仍然不能把手电筒状态改写成成功。真机日志中分别记录释放结果和读取结果,问题定位时先确认硬件状态,再分析页面提示。

只有在资源释放结果确认后,页面才将本次操作标记为完成;任何清理失败都进入可重试状态,并保留任务 ID 供现场人员继续处理。

这条规则适用于取消、返回和进程恢复,不应只在“检查成功”按钮的正常路径执行。

真机验收表中同时保留开关调用日志、页面状态和硬件探针结果,三者一致后才记录为通过。

这比单独截图更能证明清理路径真的执行。

日志、页面和硬件结果必须使用同一个操作 ID。

错误提示和测试边界。

权限拒绝是可操作的用户问题,应提示重新授权;设备无闪光灯是能力缺失,应允许跳过照明继续巡检;开关 Promise 拒绝是系统或资源错误,应进入错误状态并尝试关闭。三类错误不能都显示“现场检查失败”。

单元测试验证业务层的开关序列,不假设测试环境真的有闪光灯:

it('turns the torch off when a later check fails', async () => {
  const calls: boolean[] = [];
  jest.spyOn(Torch, 'switchState').mockImplementation(async next => {
    calls.push(next);
  });
  jest.spyOn(VolumeControl, 'getVolume').mockRejectedValue(
    new Error('volume service unavailable'),
  );

  await expect(runFieldCheck()).rejects.toThrow('volume service unavailable');
  expect(calls).toEqual([true, false]);
});

真机再确认开关结果、权限弹窗和页面退出后的硬件状态。自动化通过不等于设备真的发光,截图也不能证明退出后已经关闭,必须在关闭动作后用独立探针或下一次开启前的状态检查进行交叉核对。

六、真机验证

验证项实测结果结论
HAP 安装与启动RN能力库 0.3.1 可启动通过
触感反馈返回“中等强度反馈已触发”通过
手电筒开启/关闭开启成功,流程结束后关闭通过
音量读取返回媒体音量 50%,未改变原值通过
退出清理页面退出后照明保持关闭通过

受测设备为 6UMBB26319007180,系统 OpenHarmony-7.0.0.105,HAP SHA-256:a0f079982142ef5eb836f439af5e71c8f31f33c4988e80ab2a7575bee64b1f03。触感、手电筒、音量截图均来自同一受测 HAP;测试结束后恢复了原音量。

在这里插入图片描述

图 2:expo-haptics 真机触感反馈结果。

在这里插入图片描述

图 3:react-native-torch 真机开启结果。

在这里插入图片描述

图 4:流程结束后 react-native-torch 真机关闭结果。

在这里插入图片描述

图 5:react-native-volume-control 读取媒体音量结果。

本次未覆盖无闪光灯设备、权限永久拒绝、来电或其他应用同时占用摄像头、后台服务继续持有手电筒等场景。正式接入时应增加故障注入和多设备回归,且不能把测试时为观察方便设置的设备常亮状态当成应用功能。

七、参考链接

欢迎加入 RN for OpenHarmony 社区。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐