开发工具: 华为云码道

本文配套仓库: oh-react-native/react-native-fold-detection

上游仓库: logicwind/react-native-fold-detection

react-native-fold-detection 将折叠状态与窗口内布局信息封装为 React Native Provider 和 Hook,应用可以依据系统返回的数据调整页面布局。本文以 @react-native-ohos/react-native-fold-detection 0.3.2-0.0.1 为例,介绍源码准备、OHOS 工程配置、原生实现和 example 设备运行。

本适配保留上游 Android/iOS 实现,新增鸿蒙支持。源码位于 AtomGit 配套仓库,文中的代码以提交 4d7627331b00f1a7d3d00c6b5eb2b147ec6639d0 为参考。配套仓库包含对应的鸿蒙实现和示例工程,文章中的版本、接口与运行结果以该仓库为准。


在这里插入图片描述

给初学者的阅读提示:
本文会同时出现四个目录或产物:插件仓库是源码所在的位置,example/ 是用来演示插件的应用,HAR 是插件的鸿蒙原生模块包,HAP 是最终安装到手机上的应用包。除非特别说明,命令都在插件仓库根目录执行。

可以先记住几个名词:RNOH 是 React Native 连接鸿蒙的运行层,ArkTS 是鸿蒙原生代码使用的语言,TurboModule 是 React Native 调用原生能力的一种模块,Metro 是把 JS/TS 打包成应用资源的工具。调用链是:React Native 页面调用 JS/TS 接口,RNOH 把调用转给 ArkTS 原生模块,ArkTS 再调用 HarmonyOS 系统能力,结果沿原路返回页面。

一、插件简介与适配目标

折叠适配既涉及是否支持折叠,也涉及当前状态、折痕方向以及折痕在应用窗口中的位置。本库保留上游单折痕模型,通过系统显示和窗口接口生成布局快照。

例如,阅读页面可以根据窗口内折痕分栏,拍摄页面可以在可确认的桌面姿态下重新安排控件。没有折痕几何数据时应保留普通布局,不应凭屏幕尺寸虚构铰链位置。

Provider 订阅 onLayoutInfoChange 后启动原生监听,事件负载为 {displayFeatures: LayoutInfo}。多个 Provider 共享原生监听;最后一个卸载停止。上游只有 FLAT/HALF_OPENED,闭合、未知和不支持场景不能表达独立闭合枚举,isFlat 不等于物理完全展开。


二、环境准备

环境搭建参考社区文档:React Native OpenHarmony 环境初始化,完成 Node、对应 RN/RNOH 依赖、环境变量和 DevEco Studio 配置;框架能力与版本关系见 ohos_react_native。框架版本按下表选择,不直接套用最新分支。

完成后,在宿主机终端执行以下命令,确认当前工具链,并能发现目标设备:

node --version
npm --version
hdc list targets

node --version 和 npm --version 用来确认 Node.js 与包管理器可用;hdc list targets 用来确认电脑已经发现鸿蒙设备或模拟器。前两条命令只要能打印版本号即可,最后一条至少应出现一个设备 ID;如果列表为空,先处理 USB 调试授权或模拟器连接,再继续后面的构建。

工程使用的工具链和 SDK 配置如下:

项目版本或配置用途
Node.js24.15.0验证环境中的版本
React Native0.84.1鸿蒙宿主 RN 版本
React19.2.3与鸿蒙宿主配套
RNOH / Harmony CLI0.84.3JS 框架与鸿蒙 CLI
OHPM RNOH0.84.3原生运行时依赖
compileSdkVersion26.0.0编译 SDK
targetSdkVersion26.0.0应用目标行为版本
compatibleSdkVersion5.0.5(17)工程最低安装配置,不是最低设备实测结论
DevEco Studio26.0.0 Release(HarmonyOS SDK 26.0.0 / API 26)打开并构建 example/harmony 工程
适配包版本0.3.2-0.0.1package.json 中的交付版本
原生实现ArkTS / C++系统能力与 RNOH 桥接
插件产物harmony/fold_detection.har供宿主消费的 release HAR

鸿蒙工具依赖位于 example/harmony/package.json,与上游根目录及 Android/iOS 示例依赖分开。不要从旧示例的 RN 版本推断鸿蒙运行版本。

在这张表里,React Native 和 React 是页面使用的 JavaScript 层;RNOH 和 Harmony CLI 负责把这层代码接到鸿蒙工程;compileSdkVersion 是编译时使用的 API,targetSdkVersion 是应用面向的系统行为版本,compatibleSdkVersion 是工程声明的最低兼容版本。HAR 是插件交付包,HAP 是可以安装到设备上的应用包。

2.1 开发套件版本与工程中的 SDK 版本配置

26.0.0 是本工程 compile/target 字段值,对应编译 API 26;5.0.5(17) 声明最低兼容 API 17。SDK、IDE 版本和手机系统版本是不同信息。

对应 example/harmony/build-profile.json5 的 product 配置如下,合并到现有工程时保留其他产品和模块:

{
  "name": "default",
  "compileSdkVersion": "26.0.0",
  "compatibleSdkVersion": "5.0.5(17)",
  "targetSdkVersion": "26.0.0",
  "runtimeOS": "HarmonyOS"
}

这组配置使用 API 26 SDK 编译,并以 API 26 为目标版本,最低兼容 API 17。安装后能否使用对应能力,还取决于设备系统和具体硬件,最低配置不代表对应版本设备已通过真机验证。


三、从源码仓库开始准备适配工程

3.1 将上游源码同步到 AtomGit

适配已有第三方插件时,先从 npm 包信息或项目 README 确认上游地址、包名、版本和许可证。同步仓库时保留源码、许可证和提交历史,方便后续跟踪上游更新。

本例上游为 @logicwind/react-native-fold-detection,基线版本 0.3.2,采用 MIT 许可证。当前配套仓库已经同步了适配结果;直接接入时无需再次导入上游。

需要继续维护时,在 AtomGit 网页的新建仓库流程中,找到从已有仓库导入的入口,填写上游 Git 地址,选择自己有写权限的组织或个人空间,设置目标仓库名称后执行导入。完成后检查目标分支、package.json、LICENSE 和提交历史是否完整,并记录适配起点的提交号。如果上游本身托管在 AtomGit,也可以通过 Fork 获得自己的工作仓库。

3.2 将代码拉取到宿主机

在安装了对应 RN/RNOH 工具链和 DevEco Studio 的开发电脑上打开终端,进入准备存放项目的目录,执行:

git clone https://atomgit.com/oh-react-native/react-native-fold-detection.git
cd react-native-fold-detection
pwd
ls
git remote -v
git status --short --branch
git rev-parse HEAD

git clone 会创建 react-native-fold-detection/ 目录;cd 后的位置就是下文所说的插件仓库根目录,这里应能看到 package.json、harmony/ 和 example/。Git 仓库名与 npm 包名 @react-native-ohos/react-native-fold-detection 不是同一标识。

需要使用与本文相同的代码版本时,在没有未提交修改的仓库中切换到以下提交:

git switch --detach 4d7627331b00f1a7d3d00c6b5eb2b147ec6639d0

3.3 在仓库根目录创建适配分支

配套仓库的参考分支为 main。如果只是运行示例,保留固定提交即可;需要继续修改时,从该提交创建自己的工作分支。下面是建议的新分支名,不表示远端已经存在同名分支:

git switch -c feat/ohos_fold_detection_0.3.2-0.0.1
git branch --show-current

如果该分支已存在,使用 git switch feat/ohos_fold_detection_0.3.2-0.0.1 切换,并核对起点是否仍为本文参考版本。

3.4 自动补全 OHOS 适配结构

配套仓库已经包含 harmony/fold_detection/ 和 example/harmony/,直接运行示例时可以跳过结构生成。JS/TS 入口、原生 Package、C++ 桥接和宿主工程共同完成接线,不能只新建一个目录就称为适配完成。

本库的两种原生方法使用手写 C++ ARK_METHOD_METADATA 注册,不存在需要照搬的库级 codegen 命令。构建直接使用仓库中的桥接源码。

3.5 适配后的项目目录

适配后的关键目录如下:

react-native-fold-detection/
├── package.json
├── src/context/FoldingFeatureContext.tsx
├── src/types/index.ts
├── harmony/
│   ├── fold_detection.har
│   └── fold_detection/
│       ├── index.ets
│       ├── oh-package.json5
│       └── src/main/
│           ├── ets/FoldingFeatureTurboModule.ts
│           ├── ets/RNFoldDetectionPackage.ets
│           ├── cpp/
│           └── module.json5
├── example/harmony/App.tsx
├── example/harmony/entry/
├── scripts/build-harmony.sh
├── README.harmony.md
└── VALIDATION.md
文件主要职责
src/context/FoldingFeatureContext.tsx业务公开接口及 RN 层行为
src/types/index.ts类型/模块协议
harmony/fold_detection/src/main/ets/FoldingFeatureTurboModule.ts系统能力与生命周期
RNFoldDetectionPackage.ets注册 UI TurboModule 工厂
RNFoldDetectionPackage.hC++ Package 与模块映射
example/harmony/entry可安装宿主应用
example/harmony/App.tsx完整示例页面与诊断操作

四、JS/TS 接口与原生桥接分析

OHOS 实现需要遵循 JS/TS 层已有的方法、参数和回调约定。

对初学者来说,可以把这一层看成一条“传话链”:页面只负责调用公开函数,RNOH 负责把调用送到 ArkTS,ArkTS 负责访问系统 API,最后把成功结果或错误交回 JS/TS。只改页面代码而没有接好这条链,通常会得到“找不到原生模块”或“方法未实现”。先阅读 src/context/FoldingFeatureContext.tsx 和 src/types/index.ts,再查看 harmony/fold_detection/src/main/ets/FoldingFeatureTurboModule.ts 的原生调用。适配其他已有平台的插件时,也应保留其公开接口和其他平台实现。

本例的对应关系如下:

公开入口模块协议OHOS 实现应保持的行为
FoldingFeatureProviderFoldingFeature.startListening / stopListeningdisplay 与 window 事件先注册 JS 再发当前快照,多 Provider 共享
useFoldingFeature()onLayoutInfoChange.displayFeaturescreateFoldLayout返回 layoutInfo 与派生布尔值
isTableTop / isBook / isFlatHALF_OPENED + orientation布局字段派生缺折痕时方向默认值不能当实测
错误事件onError: {error:string}系统读取或监听异常Provider 记录日志,Hook 不提供 error 字段

4.1 跨端架构与调用时序

本例通过 RNOH UI TurboModule 执行系统调用,JS 模块名称为 FoldingFeature。ArkTS 和 C++ Package 同时参与注册;JS/npm 依赖安装成功不代表原生模块已经接入。

React Native 页面

JS/TS 公开接口

RNOH FoldingFeature

C++ 桥接

ArkTS UI TurboModule

HarmonyOS 系统能力

持续事件

4.1.1 一次完整调用的时序
系统服务 FoldingFeature JS 订阅/Provider RN 页面 系统服务 FoldingFeature JS 订阅/Provider RN 页面 注册回调或挂载 Provider 先注册 JS 事件接收 请求开始监听 注册本模块系统回调 状态或读数 onLayoutInfoChange 更新页面 移除订阅或卸载 最后一个业务订阅结束时停止 注销本模块回调

4.2 数据模型与模块声明

以下声明来自 src/types/index.ts:

export enum FoldingFeatureState {
  FLAT = 'FLAT',
  HALF_OPENED = 'HALF_OPENED',
}

export enum FoldingFeatureOrientation {
  VERTICAL = 'VERTICAL',
  HORIZONTAL = 'HORIZONTAL',
}

export enum FoldingFeatureOcclusionType {
  NONE = 'NONE',
  FULL = 'FULL',
}

export type LayoutInfo = {
  state: FoldingFeatureState;
  occlusionType: FoldingFeatureOcclusionType;
  orientation: FoldingFeatureOrientation;
  isSeparating: boolean;
  isFoldSupported: boolean;
  bounds?: {
    top: number;
    bottom: number;
    left: number;
    right: number;
  };
};

Provider 订阅 onLayoutInfoChange 后启动原生监听,事件负载为 {displayFeatures: LayoutInfo}。多个 Provider 共享原生监听;最后一个卸载停止。上游只有 FLAT/HALF_OPENED,闭合、未知和不支持场景不能表达独立闭合枚举,isFlat 不等于物理完全展开。

4.3 公开 API 与接口封装

以下为 src/context/FoldingFeatureContext.tsx 的实现,保留完整上下文以说明生命周期和状态管理:

import React, {
  createContext,
  useContext,
  useEffect,
  useMemo,
  useState,
} from 'react';
import type { PropsWithChildren } from 'react';
import { NativeEventEmitter, Platform } from 'react-native';

import { retainFoldListener } from '../FoldingFeature';
import {
  FoldingFeatureOcclusionType,
  FoldingFeatureOrientation,
  FoldingFeatureState,
} from '../types';
import type { LayoutInfo } from '../types';

type FoldingFeatureContextProps = {
  layoutInfo: LayoutInfo;
  isTableTop: boolean;
  isBook: boolean;
  isFlat: boolean;
};

export const FoldingFeatureContext = createContext<FoldingFeatureContextProps>({
  layoutInfo: {
    state: FoldingFeatureState.FLAT,
    occlusionType: FoldingFeatureOcclusionType.NONE,
    orientation: FoldingFeatureOrientation.VERTICAL,
    isSeparating: false,
    isFoldSupported: false,
  },
  // helper state
  isTableTop: false,
  isBook: false,
  isFlat: true,
});

export const useFoldingFeature = () => {
  const context = useContext(FoldingFeatureContext);

  if (context === undefined) {
    throw new Error('useFoldingFeature was used outside of its provider');
  }

  if (Platform.OS === 'ios') {
    return {
      layoutInfo: {},
      isTableTop: false,
      isBook: false,
      isFlat: true,
    };
  }

  return context;
};

export const FoldingFeatureProvider = ({ children }: PropsWithChildren<{}>) => {
  const value = useProvideFunc();

  if (Platform.OS === 'ios') {
    return <>{children}</>;
  }

  return (
    <FoldingFeatureContext.Provider value={value}>
      {children}
    </FoldingFeatureContext.Provider>
  );
};

const useProvideFunc = (): FoldingFeatureContextProps => {
  const [layoutInfo, setLayoutInfo] = useState<LayoutInfo>({
    state: FoldingFeatureState.FLAT,
    occlusionType: FoldingFeatureOcclusionType.NONE,
    orientation: FoldingFeatureOrientation.VERTICAL,
    isSeparating: false,
    isFoldSupported: false,
  });

  const updateLayoutInfo = (event: LayoutInfo) => {
    setLayoutInfo(event);
  };

  const isTableTop = useMemo(() => {
    return (
      layoutInfo.state === FoldingFeatureState.HALF_OPENED &&
      layoutInfo.orientation === FoldingFeatureOrientation.HORIZONTAL
    );
  }, [layoutInfo]);

  const isBook = useMemo(() => {
    return (
      layoutInfo.state === FoldingFeatureState.HALF_OPENED &&
      layoutInfo.orientation === FoldingFeatureOrientation.VERTICAL
    );
  }, [layoutInfo]);

  const isFlat = useMemo(() => {
    return !(isTableTop || isBook);
  }, [isTableTop, isBook]);

  useEffect(() => {
    if (Platform.OS === 'ios') {
      return; // Just return early from the effect
    }

    const eventEmitter = new NativeEventEmitter();
    const layoutSubscription = eventEmitter.addListener(
      'onLayoutInfoChange',
      (event) => {
        if (event?.displayFeatures) {
          updateLayoutInfo(event.displayFeatures);
        }
      }
    );

    const errorSubscription = eventEmitter.addListener('onError', (event) => {
      if (event?.error) {
        console.log('FoldingFeature', event.error);
      }
    });

    let release: () => void;
    try {
      // Subscribe before starting: Harmony emits an initial snapshot immediately.
      release = retainFoldListener();
    } catch (error) {
      layoutSubscription.remove();
      errorSubscription.remove();
      throw error;
    }

    return () => {
      layoutSubscription.remove();
      errorSubscription.remove();
      release();
    };
  }, []);

  return {
    layoutInfo,
    isTableTop,
    isBook,
    isFlat,
  };
};

4.4 模块协议与资源处理

原生端需要保持模块名、方法名及参数语义一致。FoldingFeature 是桥接标识,不等同于 npm 包名或 HAR 文件名。

Provider 自己注册布局和错误事件,并在卸载时移除两种事件订阅与释放引用计数。业务只读取 Hook,不额外重复启动原生监听。初始默认值不能证明系统已识别;需要诊断时使用示例应用的原生事件计数和错误区域。


五、补全 OHOS 原生实现与工程配置

这一章只处理鸿蒙侧文件。插件 HAR 负责提供原生能力,example/harmony/entry 负责把这些能力装进一个可以安装的 HAP;两者都配置好,手机上的示例页面才有机会调用到系统功能。

5.1 在 FoldingFeatureTurboModule.ts 中实现原生能力

业务沿用第四节公开接口;下面按源码说明原生层如何完成系统调用、结果转换和异常处理。

5.1.1 引入 RNOH 与系统能力

原生文件位于 harmony/fold_detection/src/main/ets/FoldingFeatureTurboModule.ts。本例实现继承 UITurboModule,通过 this.ctx 获取宿主运行时上下文。

5.1.2 核心实现与错误处理

FoldLayout 将自然屏幕折痕按 rotation 转到当前屏幕,再换算和裁剪为窗口内物理像素坐标。只有半折叠且折痕贯穿窗口才设置 isSeparating=true;柔性屏折痕不等于不透明铰链,occlusionType 保持 NONE。异步窗口请求通过 generation 防止停止后重新注册;清理失败会保留回调以便重试。

以下为该原生文件的完整源码:

// SPDX-License-Identifier: MIT
import display from '@ohos.display';
import window from '@ohos.window';
import { UITurboModule } from '@rnoh/react-native-openharmony/ts';
import { createFoldLayout, Rect } from './FoldLayout';

export class FoldingFeatureTurboModule extends UITurboModule {
  static readonly NAME: string = 'FoldingFeature';
  private listening: boolean = false;
  private generation: number = 0;
  private cleanups: Array<() => void> = [];
  private hostWindow: window.Window | undefined;
  private readonly onFold = (_status: display.FoldStatus): void => { this.emitSnapshot(); };
  private readonly onMode = (_mode: display.FoldDisplayMode): void => { this.emitSnapshot(); };
  private readonly onDisplay = (_id: number): void => { this.emitSnapshot(); };
  private readonly onWindow = (_options: window.RectChangeOptions): void => { this.emitSnapshot(); };

  startListening(): void {
    if (this.listening) { this.emitSnapshot(); return; }
    if (this.cleanups.length > 0) {
      this.stopListening();
      if (this.cleanups.length > 0) {
        this.emitError('startListening', new Error('Previous SDK subscriptions could not be removed'));
        return;
      }
    }
    this.listening = true;
    const generation = ++this.generation;
    try {
      display.on('foldStatusChange', this.onFold);
      this.cleanups.push(() => display.off('foldStatusChange', this.onFold));
      display.on('foldDisplayModeChange', this.onMode);
      this.cleanups.push(() => display.off('foldDisplayModeChange', this.onMode));
      display.on('change', this.onDisplay);
      this.cleanups.push(() => display.off('change', this.onDisplay));
      this.cleanups.push(this.ctx.rnInstance.subscribeToLifecycleEvents('FOREGROUND', () => this.emitSnapshot()));
      this.emitSnapshot();
      this.ctx.rnInstance.getRNWindow().then((hostWindow: window.Window) => {
        if (!this.listening || generation !== this.generation) { return; }
        this.hostWindow = hostWindow;
        try {
          hostWindow.on('windowRectChange', this.onWindow);
          this.cleanups.push(() => hostWindow.off('windowRectChange', this.onWindow));
          this.emitSnapshot();
        } catch (error) { this.emitError('watch window', error as Error); }
      }).catch((error: Error) => {
        if (this.listening && generation === this.generation) { this.emitError('get window', error); }
      });
    } catch (error) {
      this.stopListening();
      this.emitError('startListening', error as Error);
    }
  }

  stopListening(): void {
    this.listening = false;
    ++this.generation;
    this.hostWindow = undefined;
    // Always attempt all removals; one SDK failure must not leak other listeners.
    const pending = this.cleanups;
    this.cleanups = [];
    pending.reverse().forEach((cleanup: () => void) => {
      try { cleanup(); } catch (error) {
        this.cleanups.push(cleanup);
        this.emitError('stopListening', error as Error);
      }
    });
  }

  __onDestroy__(): void { this.stopListening(); }

  private emitSnapshot(): void {
    if (!this.listening) { return; }
    try {
      const supported = display.isFoldable();
      const status = supported ? display.getFoldStatus() : display.FoldStatus.FOLD_STATUS_UNKNOWN;
      const screen = display.getDefaultDisplaySync();
      let crease: Rect | undefined;
      let viewport: Rect | undefined;
      if (supported && status !== display.FoldStatus.FOLD_STATUS_UNKNOWN &&
          status !== display.FoldStatus.FOLD_STATUS_FOLDED && status <= 3) {
        try {
          const region = display.getCurrentFoldCreaseRegion();
          // Some emulator images return undefined despite the SDK's non-null type.
          // Bounds are optional: missing geometry must not invalidate fold state.
          if (region && region.displayId === screen.id) { crease = region.creaseRects[0]; }
        } catch (error) { this.emitError('get crease region', error as Error); }
      }
      if (this.hostWindow) {
        const properties = this.hostWindow.getWindowProperties();
        if (properties.displayId === undefined || properties.displayId === screen.id) {
          viewport = properties.windowRect;
        }
      }
      if (status > 3) { this.emitError('read posture', new Error('Multiple fold axes are not representable by the upstream single-hinge API')); }
      const layout = createFoldLayout(supported, status, crease, viewport,
        screen.rotation, screen.width, screen.height);
      this.ctx.rnInstance.emitDeviceEvent('onLayoutInfoChange', { displayFeatures: layout });
    } catch (error) { this.emitError('read folding feature', error as Error); }
  }

  private emitError(operation: string, error: Error): void {
    this.ctx.rnInstance.emitDeviceEvent('onError', { error: `${operation}: ${error.message}` });
  }
}
5.1.3 数据转换与边界处理

辅助文件 harmony/fold_detection/src/main/ets/FoldLayout.ts 将纯数据处理与系统读取分开,便于覆盖边界;测试系统替身不能代替真机能力。

// SPDX-License-Identifier: MIT
export interface Rect { left: number; top: number; width: number; height: number; }
export interface Bounds { left: number; top: number; right: number; bottom: number; }
export interface FoldLayout {
  state: string;
  orientation: string;
  occlusionType: string;
  isSeparating: boolean;
  isFoldSupported: boolean;
  bounds?: Bounds;
}

// The public upstream model represents one hinge. Do not invent a combined
// posture for devices with two fold axes (statuses 11, 12, 13, 21, 22, 23).
export function createFoldLayout(supported: boolean, status: number, crease: Rect | undefined,
  viewport: Rect | undefined, rotation: number, width: number, height: number): FoldLayout {
  const result: FoldLayout = {
    state: supported && status === 3 ? 'HALF_OPENED' : 'FLAT',
    orientation: 'VERTICAL', occlusionType: 'NONE',
    isSeparating: false, isFoldSupported: supported
  };
  if (!supported || status === 0 || status === 2 || status > 3 || !crease) { return result; }
  // FoldCreaseRegion uses natural-display pixels. Rotate into current screen
  // coordinates, then translate/clip to the host window (also physical pixels).
  let rect: Rect = crease;
  if (rotation === 1) {
    rect = { left: width - crease.top - crease.height, top: crease.left,
      width: crease.height, height: crease.width };
  } else if (rotation === 2) {
    rect = { left: width - crease.left - crease.width, top: height - crease.top - crease.height,
      width: crease.width, height: crease.height };
  } else if (rotation === 3) {
    rect = { left: crease.top, top: height - crease.left - crease.width,
      width: crease.height, height: crease.width };
  }
  const horizontal = rect.width > rect.height;
  result.orientation = horizontal ? 'HORIZONTAL' : 'VERTICAL';
  if (!viewport) { return result; }
  const left = Math.max(0, rect.left - viewport.left);
  const top = Math.max(0, rect.top - viewport.top);
  const right = Math.min(viewport.width, rect.left + rect.width - viewport.left);
  const bottom = Math.min(viewport.height, rect.top + rect.height - viewport.top);
  // An off-window crease cannot split the application's content. Zero-width
  // or zero-height fold lines remain valid if they lie inside the window.
  if (left > right || top > bottom ||
      (horizontal ? top <= 0 || bottom >= viewport.height : left <= 0 || right >= viewport.width)) {
    return result;
  }
  result.bounds = { left, top, right, bottom };
  result.isSeparating = status === 3 && (horizontal
    ? left === 0 && right === viewport.width
    : top === 0 && bottom === viewport.height);
  // A flexible OLED crease is not an opaque hinge, regardless of its thickness.
  return result;
}
5.1.4 调用结束与实例销毁

Provider 自己注册布局和错误事件,并在卸载时移除两种事件订阅与释放引用计数。业务只读取 Hook,不额外重复启动原生监听。初始默认值不能证明系统已识别;需要诊断时使用示例应用的原生事件计数和错误区域。

5.2 声明插件和宿主权限

本库上述系统能力无需额外运行时权限,不应沿用其他传感器或定位库的权限清单。

5.2.1 插件 HAR 的权限

harmony/fold_detection/src/main/module.json5 声明 HAR 模块,本库没有额外 requestPermissions。能力是否存在仍需按系统和设备判断。

5.2.2 应用 entry 的权限

example/harmony/entry/src/main/module.json5 中的 ohos.permission.INTERNET 用于 RN 宿主的调试连接,不是此库读取或操作系统能力所必需的授权。

最终安装的是宿主应用。修改 module 配置时应合并现有字段,保留原有 Ability 等配置;权限声明和运行时授权是两个步骤。

5.3 注册并导出插件

ArkTS Package 注册如下,对应文件为 harmony/fold_detection/src/main/ets/RNFoldDetectionPackage.ets:

// SPDX-License-Identifier: MIT
import { RNOHPackage, UITurboModule, UITurboModuleContext } from '@rnoh/react-native-openharmony';
import { FoldingFeatureTurboModule } from './FoldingFeatureTurboModule';

export class RNFoldDetectionPackage extends RNOHPackage {
  getUITurboModuleFactoryByNameMap(): Map<string, (ctx: UITurboModuleContext) => UITurboModule> {
    return new Map<string, (ctx: UITurboModuleContext) => UITurboModule>([
      [FoldingFeatureTurboModule.NAME,
        (ctx: UITurboModuleContext): UITurboModule => new FoldingFeatureTurboModule(ctx)]
    ]);
  }
}

C++ Package 对应文件为 harmony/fold_detection/src/main/cpp/RNFoldDetectionPackage.h:

// SPDX-License-Identifier: MIT
#pragma once
#include "RNOH/Package.h"
#include "FoldingFeature.h"

namespace rnoh {
class FoldDetectionTurboModuleFactoryDelegate : public TurboModuleFactoryDelegate {
 public:
  SharedTurboModule createTurboModule(Context ctx, const std::string &name) const override {
    if (name == "FoldingFeature") { return std::make_shared<FoldingFeature>(ctx, name); }
    return nullptr;
  }
};
class RNFoldDetectionPackage : public Package {
 public:
  explicit RNFoldDetectionPackage(Package::Context ctx) : Package(ctx) {}
  std::unique_ptr<TurboModuleFactoryDelegate> createTurboModuleFactoryDelegate() override {
    return std::make_unique<FoldDetectionTurboModuleFactoryDelegate>();
  }
};
} // namespace rnoh

两种 void 方法的手写映射如下:

// SPDX-License-Identifier: MIT
#include "FoldingFeature.h"

namespace rnoh {
FoldingFeature::FoldingFeature(const ArkTSTurboModule::Context ctx, const std::string name)
    : ArkTSTurboModule(ctx, name) {
  methodMap_ = {
    ARK_METHOD_METADATA(startListening, 0),
    ARK_METHOD_METADATA(stopListening, 0),
  };
}
} // namespace rnoh

HAR 入口 harmony/fold_detection/index.ets 导出 Package:

// SPDX-License-Identifier: MIT
export * from './ts';
export { RNFoldDetectionPackage, RNFoldDetectionPackage as default } from './src/main/ets/RNFoldDetectionPackage';

package.json 中的自动链接元数据如下:

{
  "harmony": {
    "alias": "@logicwind/react-native-fold-detection",
    "autolinking": {
      "ohPackageName": "@react-native-ohos/react-native-fold-detection",
      "etsPackageClassName": "RNFoldDetectionPackage",
      "cppPackageClassName": "RNFoldDetectionPackage",
      "cmakeLibraryTargetName": "rnoh_fold_detection"
    }
  }
}

宿主启用自动链接后,还必须调用生成的 ArkTS/C++ 工厂,并在 CMake 中包含 autolinking.cmake、执行 autolink_libraries(rnoh_app)。只生成文件不等于接入完成。自动和手动注册选择一种,避免重复注册同一个 Package。

5.4 检查 example 的 OHOS 应用结构

示例工程的 example/harmony/build-profile.json5 同时包含 entry 和库模块。下面只列出需要关注的配置,合并时保留其他模块:

{
  "modules": [
    {
      "name": "entry",
      "srcPath": "./entry",
      "targets": [
        {
          "name": "default",
          "applyToProducts": [
            "default"
          ]
        }
      ]
    },
    {
      "name": "fold_detection",
      "srcPath": "../../harmony/fold_detection",
      "targets": [
        {
          "name": "default"
        }
      ]
    }
  ]
}

配置后,在 DevEco Studio 中执行一次 Sync Project。如果 entry 模块正常识别,Project 视图中会出现 entry,并能打开 entry 模块的签名配置。


六、补全交付文件并提交适配分支

6.1 除代码外还要补全哪些文件

代码适配完成后,还需要整理安装说明、接口文档、版本记录和开源信息。接收仓库有专用模板时,按其格式填写:

文件应写清楚的内容
README.OpenSource上游名称、源码地址、基线版本与 MIT 许可
README.md / README.upstream.md项目说明、上游信息与平台入口
README.harmony.md鸿蒙安装、配套版本、权限、接口和限制
VALIDATION.md测试、构建、设备场景及未覆盖项
LICENSE保留上游许可证和版权
package.json / index 类型入口包名、版本、导出、构建脚本与发布文件;类型入口按实际 package.json
harmony/fold_detection/oh-package.json5OHPM 依赖、原生入口与版本
harmony/fold_detection.har已构建的 release HAR,含消费者所需 C++ 源码
.gitignore排除构建缓存和本机签名材料

README.OpenSource 记录库本身的来源与版本。本例上游为 @logicwind/react-native-fold-detection 0.3.2,交付包为 @react-native-ohos/react-native-fold-detection 0.3.2-0.0.1;RN 和 HarmonyOS SDK 版本写入环境说明。OAT.xml 的语法有效性与官方扫描结果应分别记录。

6.2 提交前检查

提交前先完成第八节的插件、example 和真机测试,再从根目录检查改动:

git branch --show-current
git diff --check
git status --short
git diff --stat
git diff

检查 diff 中的接口、平台注册和依赖变化,移除本机路径及签名信息,并使文档中的版本和分支与提交内容一致。

6.3 提交并推送到 AtomGit

文档和代码整理完成后,在自己的维护分支根目录暂存并提交。以下文件清单覆盖本库主要改动,新增其他文件时逐项补充:

git add package.json src/context/FoldingFeatureContext.tsx src/types/index.ts harmony scripts
git add README.md README.harmony.md README.OpenSource VALIDATION.md
git diff --cached --check
git diff --cached --stat
git diff --cached
git commit -m "docs: document OHOS support for react-native-fold-detection 0.3.2-0.0.1"
git remote -v
git branch --show-current
git push -u origin feat/ohos_fold_detection_0.3.2-0.0.1

DevEco 可能向 example/harmony/build-profile.json5 写入本机签名配置,需要在提交前从暂存内容中移除。暂存 example 时逐文件核对;推送时,origin 应指向自己有权限的 AtomGit 仓库,当前分支为前面创建的维护分支。

推送后在 AtomGit 发起合并请求,说明上游来源和版本、OHOS 实现范围、依赖及权限、测试环境、操作结果、已知限制,并附 Demo 运行图。目标分支和评审流程以接收仓库要求为准。


七、使用插件包内 example 演示接入

这一章站在使用者角度操作:先安装依赖,再让示例应用找到插件,最后点击页面按钮或执行调用。遇到问题时,先确认示例使用的是本章指定的包来源,避免把上游包和鸿蒙适配包混在一起。

仓库自带 example/,可以直接用来调试插件和体验折叠姿态检测。鸿蒙实际页面为 example/harmony/App.tsx,原生工程为 example/harmony。

7.1 本地适配时使用源码依赖

源码联调使用 example/harmony/metro.config.js 和 scripts/build-harmony.sh;不要改成 file:../,否则可能跳过仓库已有的 Harmony 配置。Metro 的项目根目录、watchFolders、别名和入口配置共同决定 JS 来源;示例 OHPM 指向库源码模块。只修改 JavaScript 后可以重新打包;原生代码变化还需重新构建。

按仓库文档,在插件根目录安装对应工具依赖:

npm install
cd example/harmony
npm install --workspaces=false
cd ../..

安装完成后,终端会回到命令提示符,并在项目中生成或更新 node_modules 与锁文件;如果出现红色错误,先解决依赖安装问题,不要直接进入构建步骤。

下面是本库的 Harmony Metro 配置。上游 Android/iOS 配置不能直接用于鸿蒙:

const path = require('path');
const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
const {createHarmonyMetroConfig} = require('@react-native-oh/react-native-harmony/metro.config');
const projectRoot = __dirname;
module.exports = mergeConfig(
  getDefaultConfig(projectRoot),
  createHarmonyMetroConfig({reactNativeHarmonyPackageName: '@react-native-oh/react-native-harmony'}),
  {
    projectRoot,
    watchFolders: [path.resolve(__dirname, '../..')],
    resolver: {
      disableHierarchicalLookup: true,
      nodeModulesPaths: [path.join(__dirname, 'node_modules')],
      extraNodeModules: {'@logicwind/react-native-fold-detection': path.resolve(__dirname, '../..')},
    },
    transformer: {babelTransformerPath: require.resolve('./transformer')},
  },
);

7.2 通过 AtomGit 源码构建并引入插件

当前没有可直接安装的 npm 版本。先按第三章从 AtomGit 获取固定提交,构建并打包,再在业务应用中安装生成的 tgz 文件。Git 仓库地址不能直接当作 npm 依赖使用。

以下命令在插件根目录执行;先完成 7.1 的依赖安装:

npm run build:har
mkdir -p artifacts
npm pack --pack-destination artifacts

产物名为 react-native-ohos-react-native-fold-detection-0.3.2-0.0.1.tgz。在采用 RN 0.84.1 / RNOH 0.84.3 的业务应用 JS 根目录执行,将绝对路径改为本机生成的文件位置:
把下一条命令中的 /absolute/path/to/...tgz 替换为你实际生成的 tgz 文件路径;示例路径不能直接照抄。

npm install /absolute/path/to/react-native-ohos-react-native-fold-detection-0.3.2-0.0.1.tgz
npx react-native link-harmony --harmony-project-path ./harmony
cd harmony
ohpm install

上述消费工程假设原生根目录为 harmony/。布局不同应调整 --harmony-project-path 和依赖路径。应用的 Harmony Metro 应使用 createHarmonyMetroConfig;包内 harmony.alias 支持上游导入名 @logicwind/react-native-fold-detection,TypeScript 的 paths 需要独立配置。下面最小页面直接使用适配包名,避免依赖 TypeScript 别名。

自动链接生成工厂后按 5.3 接入宿主。如果选择手动接入,ArkTS 注册 RNFoldDetectionPackage、C++ 注册 rnoh::RNFoldDetectionPackage,CMake 添加 HAR 内的 src/main/cpp 并链接 rnoh_fold_detection。完整手动工程见仓库 example/harmony/entry。

检查业务应用的依赖锁文件、实际 node_modules 和 OHPM 锁文件,确认 JS 与 HAR 来自同一版本,不要混用上游包的 JS 和另一个版本的原生实现。

7.3 调用接口实现折叠姿态检测

下面是已安装适配包的 RNOH 业务应用可使用的最小 App.tsx,保留本库的公开 API。

若要在仓库源码示例中使用,按 example/harmony/App.tsx 的相对位置与现有源码解析配置调整 import,保留注册入口。不要覆盖 Android/iOS 的历史示例入口。

import React, {useState} from 'react';
import {Button, ScrollView, Text} from 'react-native';
import {FoldingFeatureProvider, useFoldingFeature}
  from '@react-native-ohos/react-native-fold-detection';

function FoldPanel() {
  const {layoutInfo, isTableTop, isBook, isFlat} = useFoldingFeature();
  return (
    <>
      <Text selectable>{JSON.stringify({layoutInfo, isTableTop, isBook, isFlat}, null, 2)}</Text>
      <Text>初始值不等于已收到系统事件;没有 bounds 时不推断折痕位置。</Text>
    </>
  );
}
export default function App() {
  const [mounted, setMounted] = useState(true);
  return (
    <ScrollView contentContainerStyle={{padding: 24, gap: 16}}>
      <Button title="挂载 / 卸载 Provider" onPress={() => setMounted(value => !value)} />
      {mounted ? <FoldingFeatureProvider><FoldPanel /></FoldingFeatureProvider>
        : <Text>Provider 已卸载</Text>}
    </ScrollView>
  );
}

7.4 页面退出时处理资源

Provider 自己注册布局和错误事件,并在卸载时移除两种事件订阅与释放引用计数。业务只读取 Hook,不额外重复启动原生监听。初始默认值不能证明系统已识别;需要诊断时使用示例应用的原生事件计数和错误区域。


八、验证、构建与鸿蒙设备运行效果

建议按“工具链—设备—构建产物—页面功能”的顺序排查。先确认命令能运行,再确认 hdc 能看到设备,然后检查是否生成 HAP,最后才判断插件功能;这样可以快速分辨是环境问题、安装问题还是接口问题。 文中的 <device-id> 是占位符,请用 hdc list targets 输出的真实设备 ID 替换。

8.1 分别验证插件与 example

以下命令作为复现步骤,在已完成 7.1 安装的插件仓库根目录执行:

npm test -- --runInBand
npm run typecheck
npm run build
npm run build:har
npm run build:example
npm run verify:integration

verify:integration 会创建一个临时应用,检查 npm 包、HAR、自动链接和打包能否一起工作。命令运行前先准备好应用的签名配置;如果使用过临时工程,后续直接按该工程的构建流程继续即可。

测试覆盖快照、多 Provider、旋转映射、窗口裁剪、无折叠能力、双折轴回退、异步竞态、SDK 异常和空折痕等情况。应用接入时还要按第八章步骤确认源码示例和打包结果。

源码测试覆盖接口和业务逻辑;系统能力与设备行为需要在鸿蒙环境中验证。

8.2 确认设备连接

hdc list targets

设备首次连接电脑时,需要在手机端确认调试授权。列表为空时,检查 USB 连接、调试模式和电脑授权。

8.3 配置签名

真机安装的 HAP 通常需要有效签名。推荐使用 DevEco Studio 为 entry 模块配置签名:

  1. 用 DevEco Studio 打开 example/harmony,不是仓库根目录;
  2. 等待工程 Sync 成功,确认 Project 视图中存在 entry 模块;
  3. 打开 File > Project Structure > Signing Configs;
  4. 为 default product 选择或生成签名;
  5. 确认设备、应用包名、证书和 Profile 匹配;
  6. 再回到终端执行构建,或在 DevEco 运行 entry。

签名材料保存在本机,公开仓库中只保留构建所需的通用配置。HAR 不需要应用签名;要验收最终交付 HAR,应使用独立 npm 消费宿主,而不是以源码宿主代替。

8.4 运行示例

从插件根目录构建源码示例:

npm run build:example

本库脚本生成离线生产 JS bundle,并构建默认 debug HAP,不要求另开 Metro 服务。典型产物位于:

example/harmony/entry/build/default/outputs/default/

未签名文件 entry-default-unsigned.hap 仅能说明打包完成。配置有效签名并生成 entry-default-signed.hap 后,可在仓库根目录安装:

hdc -t <device-id> install -r example/harmony/entry/build/default/outputs/default/entry-default-signed.hap

将 <device-id> 替换为设备列表中的实际 ID;安装后从设备应用图标启动,或在 DevEco 直接运行 entry。构建工具默认来自 macOS DevEco Studio,其他位置使用脚本支持的 DEVECO_ROOT、DEVECO_SDK_HOME、OHPM_BIN、HVIGOR_JS 配置。

8.5 在设备上测试核心操作

  1. 检查示例应用的 Native bridge、原生事件与 Provider 数据。
  2. 切换展开、半折叠、闭合,对照系统状态及上游映射。
  3. 停止监听后切换状态,检查事件计数保持,再启动获取快照。
  4. 卸载重挂 Provider、前后台与冷启动;只有 SDK 返回真实折痕时才验收 bounds、方向和分隔。

页面默认值或“已请求”文本不等于系统操作完成。查询要检查结果来源,监听要观察真实事件,窗口操作要观察实际系统显示。

8.6 鸿蒙设备运行效果

示例覆盖展开、半折叠和闭合状态,Provider 返回值与原生模块保持一致;重复启动、停止后切换、回到前台和重新挂载等生命周期场景也有对应处理。

模拟器旧折痕接口返回 undefined,新接口返回空 creaseRects,当前正确省略 bounds。半折叠时 isBook=true 来自默认 VERTICAL,不能证明真实铰链方向。实际 bounds、tabletop、分隔、实体折叠屏和屏幕旋转仍未验收;修正版本非折叠真机完整回归也未完成。


九、FAQ:适配过程与使用问题

9.1 Missing SDK components

这个错误发生在 Hvigor 同步阶段。通常需要检查构建工具使用的 SDK 路径、组件是否完整,以及 Hvigor 与 SDK 的版本是否匹配。

处理顺序:

  1. 在 DevEco Studio SDK Manager 中确认 API 26 组件已经下载完整;
  2. 检查构建脚本与 DevEco 使用的 SDK 路径是否一致;
  3. 根据实际安装位置设置 DEVECO_SDK_HOME 或其他工具覆盖变量;
  4. 确认 SDK 包中工具链、ETS、JS 和 native 组件可用;
  5. 重新执行 DevEco Studio Sync,并查看脚本输出的构建日志。
为什么连接 API 24 手机仍然会报这个错误?

因为 Sync 和 Compile 首先读取 Mac 本地 SDK。手机 API 版本只在部署、安装和运行时参与兼容判断。即使完全不连接手机,本地 SDK 不完整时也会得到相同错误。

当前工程的 compatibleSdkVersion 是 API 17,因此 API 24 在安装版本门槛上是满足的;但设备还必须支持本库使用的能力,并满足签名要求。

9.2 DevEco Studio 中看不到 entry 模块

插件的 harmony/fold_detection 目录是 HAR 模块,可安装应用的 entry 模块位于 example/harmony/entry。

请直接使用 DevEco Studio 打开:

react-native-fold-detection/example/harmony

如果仍看不到 entry,先解决 SDK Sync 错误,再检查应用 build-profile 的 modules 是否包含 ./entry。同步失败时,Project Structure 无法正确解析模块,签名界面也可能不显示 entry。

9.3 无法手动签名

签名配置依附于可构建的应用模块和 product。只有 HAR 插件模块、工程 Sync 失败,或者打开了错误目录时,DevEco Studio 都可能无法提供 entry 签名入口。

建议先确认:

  • 打开的是 example/harmony;
  • SDK 组件完整并且 Sync 成功;
  • entry 的模块类型为 entry;
  • default product 和 target 已正确关联;
  • 当前账号、证书和调试设备状态有效。

9.4 isFlat=true 是否表示屏幕完全展开

不是。它由“不处于书本或桌面姿态”派生,上游模型无法独立表示闭合和未知。因此闭合或不支持也可能得到 true。

9.5 找不到原生模块 FoldingFeature

这通常表示 JS 入口找不到已注册的原生模块。新增原生插件后需要重新构建应用;检查 npm 适配包、OHPM HAR、ArkTS Package、C++ Package 和 CMake 链接,特别是自动工厂是否真正由宿主调用。不要用一个固定返回值或开发刷新替代原生接入。

9.6 重复操作或页面退出后的行为

Provider 自己注册布局和错误事件,并在卸载时移除两种事件订阅与释放引用计数。业务只读取 Hook,不额外重复启动原生监听。初始默认值不能证明系统已识别;需要诊断时使用示例应用的原生事件计数和错误区域。

9.7 编译成功但安装失败

常见原因包括:

  • HAP 未签名或使用了错误的 Profile;
  • 设备未加入调试设备列表;
  • 包名与签名 Profile 不匹配;
  • 安装包的 compatibleSdkVersion 高于设备 API;
  • 手机上已经安装了使用不同证书签名的同包名应用。

根据安装错误码区分签名、版本和包名冲突,再处理对应配置。

9.8 RN CLI 或 Metro 使用了错误入口

在对应依赖目录确认安装的是本文配套 RN/RNOH 和 Harmony CLI。使用 example/harmony/metro.config.js,检查包的 react-native/main 入口与 Harmony 平台选择;Metro 别名不会自动给 TypeScript 提供 paths。调试时不要同时加载上游包和适配包的不同实现。

9.9 AtomGit 依赖提示找不到分支或无权限

先检查 URL 是否指向已同步的目标仓库,再确认需要的分支已推送。本文已固定到 4d7627331b00f1a7d3d00c6b5eb2b147ec6639d0;自己新建的 feat/ohos_fold_detection_0.3.2-0.0.1 不会自动存在于远端。私有 Fork 还需在本机配置 Git 认证。

9.10 改了本地 ArkTS,Demo 为什么没变化

先检查应用消费的是源码模块还是打包的 HAR:已安装的 tgz 不会自动读取工作区修改。本地源码联调按 7.1 的配置,测试交付包则重建 HAR、重新打包并更新宿主依赖与 OHPM 锁文件。原生代码变动后停止应用并重新构建运行,不能只做 JS Fast Refresh。

9.11 半折叠有状态但没有 bounds

当前模拟器没有返回有效折痕。实现保留系统状态、省略可选坐标;VERTICAL 可能是默认值,不能拿 isBook=true 证明实体书本姿态。


相关链接

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐