稳定性编码规范

华为官方稳定性编码规范已覆盖 NDK 开发、ArkTS 侧编码、Node‑API 开发、C++ 编码、libuv 使用与案例、易错 API 使用等通用内容。

https://developer.huawei.com/consumer/cn/doc/best-practices/bpta-stability-coding-standard

本文聚焦 RNOH / React Native 场景,结合历史稳定性修复、框架代码模式和稳定性文档,对 RN 框架侧编码规范进行进一步补充和细化。 结合历史问题可以看到,框架中的高频稳定性问题主要集中在以下几个方向:

  • 生命周期结束后回调没有及时解绑,导致对象已销毁但回调仍在执行。
  • 多线程共享状态访问缺少同步保护,导致死锁、卡死或低概率崩溃。
  • 初始化和销毁阶段的状态边界不清晰,导致空指针、UAF 或断言失败。
  • JS、ArkTS 与 C++ 跨层调用缺少空值保护、异常边界和线程约束。
  • 平台兼容、混淆和模块导出方式处理不当,导致 release 包或特定系统版本下崩溃。

因此,编码时应围绕内存与对象安全、生命周期与销毁清理、并发与线程、异常与边界处理、平台与模块兼容五个维度建立稳定性规范。

1. 内存与对象安全

1.1 异步回调不得持有对象强引用

异步回调,包括 VSync、Display、Timer、HTTP、NAPI 等,不得直接捕获 this 或持有宿主对象的强引用。回调与对象生命周期必须解耦,建议统一使用 weak_ptr 弱引用,在回调执行时先 lock 并判空,如果对象已销毁则立即返回。

如果回调只需要读取少量只读数据,例如 URI、路径、配置快照、事件参数等,应优先按值拷贝到回调闭包中,而不是继续引用宿主对象内部成员。对必须回到对象实例上执行的逻辑,统一使用 weak_ptr 弱引用并在回调入口判空。

示例:

// 正确
auto weakSelf = weak_from_this();
scheduleCallback([weakSelf]() {
    auto self = weakSelf.lock();
    if (!self) return;
    self->doWork();
});

// 禁止
scheduleCallback([this]() {
    this->doWork();
});

如果异步任务执行时对象已经销毁,而回调仍持有 this,就会形成典型的 UAF 或悬空引用访问。历史上,AnimatedTurboModule、ImageComponentInstance、EventBeat、UI tick 等路径都曾出现过这类问题。

历史案例:

1.2 共享资源多线程访问必须加锁

所有会被多个线程并发访问的共享容器、缓存和状态字段,都必须做同步保护。只保护写不保护读,或者只在部分调用链加锁,都会留下数据竞争窗口。

典型高风险对象包括:

  • inflightAnimations_ 这类全局动画状态。
  • TextMeasureRegistry、FontRegistry 这类跨线程读写注册表。
  • listeners、callbacks、registry、cache 等共享集合。

当对象同时可能被 UI 线程、JS 线程、主线程或 Worker 线程访问时,必须从设计上明确访问模型,不能依赖“实际运行时大概率不会冲突”的假设。

示例:

// 正确
std::lock_guard<std::mutex> lock(mutex_);
listeners_.push_back(listener);

// 错误
listeners_.push_back(listener);

历史案例:

1.3 工厂方法和 create 接口返回值必须检查

所有工厂方法、createNode()、lookup、find、getDescriptor()、optional 风格返回值,在使用前都必须做有效性检查。不能假设返回值一定存在,也不能在收到空对象后继续执行后续逻辑。

典型风险包括:

  • NodeApi 或 ArkUINodeContext 未就绪时 createNode() 返回空。
  • registry 查询不到 descriptor 时继续向下执行。
  • lookup 返回 null 或 optional 为空时直接解引用。

修复这类问题的关键不是在崩溃点补 if,而是在对象创建和依赖获取入口统一建立失败分支和兜底逻辑。

示例:

// 正确
auto node = createNode();
if (!node) {
  return nullptr;
}
node->mount();

// 错误
auto node = createNode();
node->mount();

历史案例:

1.4 指针、成员状态和上下文对象使用前必须判空

以下对象在历史问题中反复出现空指针风险,必须在使用前显式校验:

  • 裸指针。
  • weak_ptr::lock() 得到的对象。
  • m_eventEmitter、m_props、m_state、m_surface、m_scheduler 等组件成员。
  • 跨层返回的可选对象、回调参数和状态上下文。

尤其是在生命周期切换、异步回调晚到、页面销毁后事件继续触发、窗口变化等场景下,原本在正常路径下非空的对象,在边界情况下很容易变成空值。

示例:

// 正确
if (m_eventEmitter != nullptr) {
  m_eventEmitter->dispatchEvent(event);
}

// 错误
m_eventEmitter->dispatchEvent(event);

历史案例:

1.5 避免 shared_ptr 循环引用

如果对象 A 持有对象 B 的 shared_ptr,而对象 B 又反向持有对象 A 的 shared_ptr,就会形成循环引用,导致对象长期无法释放,最终引发内存异常或资源泄漏。此时应将其中一侧改为 weak_ptr。

对于 listener、callback owner、上下文对象和 manager 之间的双向引用,代码评审时必须重点检查是否存在这类问题。

示例:

// 正确
class Child {
  std::weak_ptr<Owner> owner_;
};

// 错误
class Child {
  std::shared_ptr<Owner> owner_;
};

历史案例:

2. 生命周期与销毁清理

2.1 析构函数或 destroy() 必须停止所有异步来源

对象进入销毁阶段后,必须主动停止所有可能继续驱动该对象的外部回调源,而不能仅依赖对象被释放。需要重点检查的来源包括:

  • VSync 或 Display 回调注销。
  • Timer 取消,例如 cancelTimer、stopTimer。
  • EventBeat、动画帧驱动、onUITick 等解绑。
  • HTTP 请求中止或完成回调清理。
  • observer、listener、event emitter 的反注册。

原则上,谁注册,谁释放;在哪一层注册,就必须在同一生命周期责任边界上显式清理。

示例:

// 正确
void destroy() {
  stopTimer(timerId_);
  unsubscribeVSync();
}

// 错误
void destroy() {
  released_ = true;
}

历史案例:

2.2 销毁顺序必须与依赖链一致

销毁阶段不能只关注“对象是否析构”,还必须保证依赖链顺序正确。一般原则是:

  • 先停止依赖上游对象的使用者。
  • 再销毁被依赖的资源。
  • 最后清理管理者和全局状态。

例如 surface 停止时,必须先保证 Scheduler、UIManager、LayoutAnimation 等对 surface 的访问路径已经停止,再做 unregisterSurface 和资源释放。否则就会出现下层对象已经销毁,但上层仍在继续调用 stopSurface、updateConstraints 或动画驱动的情况。

示例:

// 正确
stopSurface();
unregisterSurface();
releaseResources();

// 错误
releaseResources();
stopSurface();

历史案例:

2.3 初始化路径必须保证依赖已 ready

任何依赖外部上下文的对象,在进入创建或访问路径前,必须确认依赖已 ready。常见要求包括:

  • ArkUINode 创建前确认 ArkUINodeContext 和 NodeApi 已初始化。
  • 窗口属性读取前确认窗口状态稳定。
  • bundle 加载、Ability 相关接口调用前确认平台能力可用。

初始化阶段的问题常见表现是:空指针、断言 abort、undefined error 或平台能力加载失败。编码时应把 ready 检查放在入口,而不是等调用失败后再补救。

示例:

// 正确
if (!context_ || !context_->nodeApi) {
  return;
}
createNode();

// 错误
createNode();

历史案例:

2.4 状态机迁移必须保持顺序一致

组件内部状态变更不得在多个事件回调中交叉触发,尤其是在滚动、拖拽、动画结束、页面退出和布局提交等高频场景下。必须保证:

  • 状态切换路径唯一且顺序稳定。
  • 回调触发不会重入改变当前状态。
  • 外部事件通知发生在内部状态完成更新之后,或通过明确设计保证顺序一致。

如果状态迁移与回调交错执行,很容易出现状态机进入非法分支,最终在下一次访问时崩溃。

示例:

// 正确
state_ = State::Idle;
emitScrollStop();

// 错误
emitScrollStop();
state_ = State::Idle;

历史案例:

2.5 注册与注销必须形成闭环

所有注册行为都必须有与之对称的注销行为,且注销必须发生在对象销毁时,而不能依赖 GC 或宿主环境自动回收。需要成对检查的接口包括:

  • addListener / removeListener。
  • subscribe / unsubscribe。
  • addEventListener / removeEventListener。
  • napi_create_reference / napi_delete_reference。
  • 请求发起 / 请求完成后的回调上下文释放。

其中 NAPI reference、NAPI value 及其关联句柄的创建、访问和释放,必须严格遵守所属 env 和线程上下文约束。不能把引用或句柄跨线程传递后在错误线程释放,也不能把“当前在 JS 线程”当作可以任意回收 NAPI 资源的充分条件。

如果注册与注销没有形成闭环,短期可能只是对象未释放,长期则会演变成线程泄漏、句柄泄漏、内存膨胀或实例销毁后回调继续执行。

示例:

// 正确
listenerId_ = addListener(callback);
removeListener(listenerId_);

// 错误
addListener(callback);

历史案例:

3. 并发与线程

3.1 多把锁的获取顺序必须全局一致

只要某个模块会同时持有两把或多把锁,就必须规定全局一致的加锁顺序。不同线程如果使用不同顺序获取同一组锁,就会形成循环等待,导致死锁。

推荐做法:

  • 使用 std::scoped_lock 或 std::lock 原子获取多把锁。
  • 在注释或模块设计中明确锁顺序约束。
  • 避免跨函数隐式持锁,防止调用方和被调用方叠加形成顺序错乱。

示例:

// 推荐
std::scoped_lock lock(mutexA, mutexB);

// 错误
std::lock_guard<std::mutex> lockA(mutexA);
std::lock_guard<std::mutex> lockB(mutexB);

FontRegistry、TextMeasureRegistry、动画注册表等都是历史上出现过锁顺序问题的高风险区域。

历史案例:

3.2 持锁期间不得发起跨线程同步等待

在持有锁的状态下,不得:

  • 调用 waitForSyncTask 之类的同步等待接口。
  • 向其他线程投递任务后阻塞等待结果。
  • 直接调用用户回调或可能重新进入当前锁域的框架回调。

正确做法是:

  • 先复制必要数据。
  • 释放锁。
  • 再执行跨线程调度或回调。

reportMount、scheduler 回调、页面生命周期切换等路径尤其要避免“持锁调度 + 等待返回”的写法。

示例:

// 正确
{
  std::lock_guard<std::mutex> lock(mutex_);
  pending_ = data;
}
runOnMainThread(task);

// 错误
std::lock_guard<std::mutex> lock(mutex_);
waitForSyncTask(task);

历史案例:

3.3 每类操作必须在规定线程上执行

不同对象和系统接口有明确的线程归属,编码时不能只看代码能不能调通,还必须保证调用线程合法。

常见要求如下:

  • ArkUI 节点操作必须在 UI 线程。
  • ArkUI 的 markDirty 这类节点脏标记操作必须放在主线程/UI 线程执行,不能在后台线程直接调用。
  • NAPI 调用必须在 JS 线程。
  • 窗口相关属性读取必须在窗口允许的线程上下文中执行。
  • 平台接口如 getSDKApiVersion 这类能力调用必须符合其线程限制。

如果某个逻辑在 Worker 线程上运行,但需要访问主线程对象,则必须显式切换到主线程队列,而不能直接跨线程访问。

示例:

// 正确
runOnMainThread([this]() { updateWindow(); });

// 错误
updateWindow();

历史案例:

3.4 共享状态的 getter 也要考虑线程安全

线程安全问题不只出现在写路径,读路径同样可能有风险。尤其是 getter 如果内部读取的是可变共享状态,就必须保证:

  • 读路径与写路径使用同一套同步保护。
  • 不返回已经失效的引用、指针或上下文。
  • 不把当前线程栈上的临时状态暴露到异步回调或其他线程。

历史上 HostObjectProxy、JSVMRuntime 等路径就曾因为 getter 跨线程共享状态而导致间歇性崩溃。

示例:

// 正确
std::string getName() {
  std::lock_guard<std::mutex> lock(mutex_);
  return name_;
}

// 错误
const std::string& getName() {
  return name_;
}

历史案例:

3.5 事务处理必须保证数据源一致和顺序稳定

在 performTransaction、mutation 分桶、切片并行处理等高频事务链路中,必须确保:

  • 前后阶段使用的是同一份 transaction 数据。
  • 不会因为并行切片或异步调度打乱 Create、Insert、Delete 等 mutation 顺序。
  • 所有依赖顺序的操作在拆分时保留顺序约束。

如果事务链路在不同阶段读取了不一致的数据副本,就可能造成挂载顺序错乱、状态不一致,最终演变成崩溃或低概率异常。

示例:

// 正确
auto &mutations = transaction.getMutations();
splitAndDispatch(mutations);

// 错误
splitAndDispatch(snapshotA);
applyLater(snapshotB);

历史案例:

4. 异常与边界处理

4.1 noexcept 只能用于真正不会抛异常的函数

noexcept 不是性能优化标签,而是异常边界承诺。凡是标注为 noexcept 的函数,都必须保证其内部调用链上不会有异常穿透出去。以下场景不能随意加 noexcept:

  • 析构函数中包含复杂逻辑、回调、资源释放或跨层调用。
  • 虚函数覆盖中调用了第三方库或自定义逻辑。
  • 系统回调接口中仍可能抛出 runtime_error、logic_error 等异常。

如果函数内部可能抛异常,应采取以下方式之一:

  • 去掉不合理的 noexcept。
  • 在边界处 try-catch 并转化为可恢复错误。
  • 把错误转为日志、状态码或显式失败分支。

否则一旦异常穿透 noexcept 边界,就会直接触发 terminate,最终表现为 SIGABRT。

示例:

// 正确
void flush() {
  mayThrow();
}

// 错误
void flush() noexcept {
  mayThrow();
}

历史案例:

4.2 非 void 函数所有分支都必须返回

声明了返回值的函数,必须保证所有控制流分支都有明确 return,不能依赖未定义行为。尤其是下面几类函数:

  • 返回对象自身引用的链式接口。
  • 返回状态枚举、布尔值或指针的工具函数。
  • switch 或多层条件判断中的状态转换函数。

如果遗漏某个分支的返回值,调用方继续使用结果时可能落入未定义状态,表现为 SIGTRAP、SIGSEGV 或后续更难追踪的逻辑错误。

示例:

// 正确
int getWidth(bool ready) {
  if (!ready) return 0;
  return width_;
}

// 错误
int getWidth(bool ready) {
  if (ready) return width_;
}

历史案例:

4.3 JS 和 ArkTS 层必须全面做空值保护

JS 和 ArkTS 层对 undefined、null 的容忍度低,未处理异常会直接演变成 JS Crash。以下场景必须做空值保护:

  • 异步回调中访问对象属性。
  • 关闭、销毁、重置路径中访问 socket、window、context 等对象。
  • 可选参数、跨层返回值和平台 API 调用结果的读取。

推荐模式:

if (this.socket == undefined) {
  return;
}
this.socket.close();

或者使用可选链、默认值和显式降级分支,而不是假设对象始终存在。

示例:

// 正确
if (socket != undefined) {
  socket.close();
}

// 错误
socket.close();

历史案例:

4.4 边界输入必须在入口做合法性检查

所有来自外部的输入,包括 props、模块参数、路径、配置、事件数据,都必须在入口处校验类型和合法性。以下情况不能直接向下传递:

  • 非法 keyboardType、枚举值或状态值。
  • 目录、文件、句柄类型混用。
  • 未提供 cache key、descriptor、component name 等关键字段。
  • 约定结构不完整的对象。

正确做法是尽早失败、给出日志并进入可恢复分支,而不是等底层代码在未知状态下崩溃。

示例:

// 正确
if (!isValidKeyboardType(type)) {
  return "default";
}

// 错误
return type;

历史案例:

4.5 容器和字符串访问必须检查边界

对 vector、array、map、string 等容器的访问,必须确认索引、位置和值存在,不能假设集合状态总是与上一步一致。尤其在并发场景或状态切换场景下,上一行读到的 size,下一行未必仍然有效。

需要重点检查的典型问题包括:

  • 删除所有 item 后继续按旧索引访问。
  • 字符串 pos 越界。
  • 文本布局结果位置计算错误。
  • 动态对象解包时未检查字段是否存在。

示例:

// 正确
if (index < items_.size()) {
  return items_[index];
}

// 错误
return items_[index];

历史案例:

4.6 排序比较器必须满足严格弱序,需要稳定结果时使用 stable_sort

在 C++ 中使用 std::sort 时,比较器必须满足严格弱序要求,不能把“相等元素”也写成可互相判定为更小。对于不满足严格弱序的比较器,std::sort 的行为属于未定义行为,不能依赖“小数据量看起来正常”或“部分输入下没有复现问题”的现象判断实现正确。一旦比较器对相等元素返回错误结果,排序过程就可能出现错误结果、异常访问或崩溃。

如果元素之间可能相等,且业务上需要保持相等元素的原始相对顺序,应使用 std::stable_sort;如果不需要稳定性,也仍然必须保证 std::sort 使用的比较器满足严格弱序,不能写成 <=、>= 或其他会破坏比较关系的形式。

示例:

// 正确
std::stable_sort(items.begin(), items.end(), [](const Item& left, const Item& right) {
  return left.priority < right.priority;
});

// 错误
std::sort(items.begin(), items.end(), [](const Item& left, const Item& right) {
  return left.priority <= right.priority;
});

历史案例:

5. 平台与模块兼容

5.1 ArkTS 中禁止手工 declare 系统函数

在 ArkTS 文件中,不应通过 declare function 的方式手工声明系统函数,例如 px2vp。因为这类声明在 release 混淆后可能无法按原始符号名解析,最终引发 undefined error 或启动崩溃。

正确做法是使用平台正式提供的能力入口或导入方式,不要自行构造运行时解析依赖。

示例:

// 正确
import { LengthMetrics } from "@kit.ArkUI";

// 错误
declare function px2vp(value: number): number;

历史案例:

5.2 ArkTS 私有字段禁止使用双下划线前缀

在 release 构建或混淆场景下,以双下划线开头的字段和方法名可能被重命名,导致运行时找不到对应属性或方法。因此:

  • 不要使用 __xxx 作为私有字段和方法命名。
  • 优先使用 _xxx 或普通语义化命名。
  • 对需要稳定暴露给运行时、反射或导出层的成员,更应避免混淆敏感命名。

示例:

// 正确
private _windowSize = 0

// 错误
private __windowSize = 0

历史案例:

5.3 平台 API 必须做版本兼容判断

系统 API 并不保证在所有 API Level 下都可用。编码时必须:

  • 先判断平台版本。
  • 再决定是否调用特定接口。
  • 对不支持的平台提供降级实现或跳过路径。

尤其是 API12 相关能力、libability_runtime.so 加载链路、页面注册能力等,历史上都出现过平台兼容缺失导致崩溃的问题。

示例:

// 正确
if (apiVersion >= 12) {
  useNewApi();
}

// 错误
useNewApi();

历史案例:

5.4 自定义组件命名不得与框架保留前缀冲突

组件注册、descriptor 查找、模块导出等路径,都依赖稳定的命名约定。自定义组件和模块不得使用框架保留前缀,例如 RCT,否则可能在运行时与框架内置对象冲突,导致注册失败或组件创建崩溃。

示例:

// 正确
export const FancyButton = ...

// 错误
export const RCTFancyButton = ...

历史案例:

5.5 导出符号和模块实现必须避免与三方库冲突

NativeNodeApi、NAPI 导出对象、动态链接符号等,都可能因为与三方库同名而被覆盖。编码时应避免使用过于泛化的导出名,并在模块边界上明确命名空间和导出策略,防止运行时符号解析错误。

示例:

// 正确
namespace rnoh {
class HarmonyNodeApi {};
}

// 错误
class NativeNodeApi {};

历史案例:

6. 代码评审中的稳定性自查清单

在提交代码或 CR 时,建议对照以下问题快速自查:

6.1 生命周期检查

  • 每一个异步回调,对象销毁后还会不会继续执行。
  • destroy() 或析构函数中是否停止了所有 VSync、Timer、HTTP、listener 和 observer。
  • 是否存在析构过程中继续触发外部回调的路径。

6.2 线程与锁检查

  • 多把锁的加锁顺序是否全局一致。
  • 持锁期间是否做了跨线程同步等待。
  • ArkUI、NAPI、窗口属性等操作是否运行在正确线程上。
  • getter 或读路径是否遗漏了线程同步保护。

6.3 空值与边界检查

  • 指针、optional、registry 查询结果是否都做了判空。
  • 容器、字符串和文本位置访问是否检查了边界。
  • 排序比较器是否满足严格弱序;元素可能相等且需要保持顺序时是否使用 stable_sort。
  • 非法 props、配置、参数值是否在入口处被拦截。

6.4 异常边界检查

  • noexcept 是否只用于真正不会抛异常的函数。
  • 所有非 void 函数是否在所有分支都返回。
  • JS 和 ArkTS 中的异步对象访问是否做了 undefined 或 null 保护。

6.5 兼容性与资源释放检查

  • 是否调用了有平台版本限制的接口,但没有做兼容判断。
  • ArkTS 中是否手工 declare 了系统函数。
  • 是否使用了双下划线前缀的敏感命名。
  • 每个 listener、reference、request、thread、cache 是否都有对称的释放路径。