第 17 章 NDK开发

98 阅读10分钟

NDK(Native Development Kit)作 HarmonyOS SDK提供的 Native API、编译脚本及工具链集合,便于开发者用 C 或 C++ 实现应用关键功能。它涵盖部分基础底层能力,像 libc 等库以及 ArkTS/JS 与 C 跨语言的 Node-API ,但无 ArkTS/JS API 完整能力。运行时,开发者可借 NDK 的 Node-API 接口访问、创建、操作 JS 对象,也允许 JS 对象使用 Native 动态库。其适用于性能敏感(如游戏、物理模拟)、复用已有 C 或 C++ 库,以及针对 CPU 特性定制库(如 Neon 加速)等场景;而纯 C 或 C++ 应用、期望在多数 HarmonyOS 设备保持兼容的应用则不建议使用。开发者需掌握 Node-API(曾用名 NAPI,基于 Node.js 的 Node-API 扩展但不完全兼容)等基本概念,C API 作为 NDK 曾用名已弃用。

17.1 Native基本知识和开发流程

进行HarmonyOS的Native开发,开发者需掌握一些前置知识。LinuxC语言编程知识必不可少,因为HarmonyOS内核、libc基础库基于POSIX等标准扩展,这有助于理解NDK开发。CMake是HarmonyOS默认支持的构建系统,要通过官方文档了解其基础用法。熟悉NodeAddons开发模式也很关键,ArkTS采用Node-API作为跨语言调用接口,掌握此开发模式能更好使用NDK中的Node-API。同时,具备Clang/LLVM编译器基础知识可编译出更优的Native动态库。NDK是HarmonyOSSDK提供的NativeAPI、编译脚本及工具链集合,用于C或C++实现应用关键功能,覆盖部分基础底层能力但无ArkTS/JSAPI完整能力,运行态可通过Node-API接口操作JS对象及让JS对象使用Native动态库。适用场景有性能敏感场景、复用已有C或C++库、针对CPU特性定制库等,而纯C或C++应用、追求多设备兼容的应用不建议使用。开发前要了解NDK目录结构,build目录存放ohos.toolchain.cmake,编译时用CMAKE_TOOLCHAIN_FILE指定路径;build-tools文件夹和llvm文件夹分别存放编译工具和编译器,之后依据需求从标准C库、Node-API等常用模块中选择进行开发。

17.1.1 NDK 组成架构

NDK(Native Development Kit)主要由以下几部分组成

  1. Native API
  • 标准库接口
    • 标准 C 库:以musl为基础提供标准C库接口,为开发者在C语言编程时提供基础的函数和类型定义,如字符串处理、内存管理等功能。
    • 标准C++库:提供C++运行时库libc++_shared,支持C++语言的特性,方便开发者使用C++进行开发,如面向对象编程、模板等功能。
  • 系统功能接口
    • 日志接口:提供打印日志到系统的HiLog接口,开发者可以利用该接口在开发和调试过程中输出关键信息,便于排查问题和监控程序运行状态。
    • 图形接口
    • Drawing:系统提供的2D图形库,允许开发者在surface上进行2D图形的绘制,可用于开发一些简单的图形界面或游戏场景。
    • OpenGL:提供 OpenGL 3D图形接口,支持开发者创建复杂的3D图形应用,如3D游戏、3D建模等。
    • 音频接口:OpenSL ES是用于 2D、3D音频加速的接口库,能够帮助开发者实现音频的播放、录制和处理等功能,提升音频方面的性能。
    • 资源访问接口:Rawfile是应用资源访问接口,可用于读取应用中打包的各种资源,如图像、音频、文本等文件。
    • 组件接口:ArkUI XComponent组件提供surface与触屏事件等接口,方便开发者开发高性能图形应用,处理用户的触摸交互等操作。
    • 并发编程接口:FFRT是基于任务的并发编程框架,帮助开发者实现多任务并发执行,提高程序的性能和响应速度。
    • 异步 IO 接口:libuv是三方异步IO库,用于处理异步输入输出操作,避免阻塞主线程,提升程序的效率。
    • 数据压缩接口:zlib库提供基本的数据压缩、解压接口,可用于处理数据的压缩存储和传输等场景。
    • 跨语言调用接口:Node - API(曾用名 NAPI)是 HarmonyOS中提供ArkTS/JS与C/C++跨语言调用的接口,允许开发者在不同语言编写的代码之间进行交互。
  1. 编译脚本
  • ohos.toolchain.cmake:存放在 build 目录下,是预定义的toolchain脚本文件。CMake编译时需要读取该文件中的默认值,如编译器架构、C++库链接方式等。在编译时,会通过CMAKE_TOOLCHAIN_FILE指出该文件的路径,以便CMake在编译时定位到该文件。
  1. 编译工具链
  • build - tools文件夹:该文件夹放置着 NDK 提供的编译工具,例如CMake就是其中重要的编译工具之一,可用于构建项目。
  • llvm 文件夹:放置 NDK 提供的编译器,这些编译器用于将C或C++ 代码编译成可在目标设备上运行的机器码。

17.1.2 Node-API 关键交互流程

ArkTS和C++之间的交互流程,主要分为以下两步:

  1. 初始化阶段

当ArkTS侧在import一个Native模块时,ArkTS引擎会调用ModuleManager加载模块对应的so及其依赖。首次加载时会触发模块的注册,将模块定义的方法属性挂载到exports对象上并返回该对象。

  1. 调用阶段

当ArkTS侧通过上述import返回的对象调用方法时,ArkTS引擎会找到并调用对应的C/C++方法。

17.1.3 Native 开发流程

下面通过DevEco Studio的NDK工程模板,来演示如何创建一个NDK工程,以及对应的工程目录介绍,并且介绍基本代码的使用。

  1. 选择Native C++工程模板

  1. 创建后目录

在工程配置页面,根据向导配置工程的基本信息后,单击Finish,工具会自动生成示例代码和相关资源,等待工程创建完成。

  • entry:应用模块,编译构建生成一个HAP。
    • src > main > cpp > types:用于存放C++的API接口描述文件
    • src > main > cpp > types > libentry > index.d.ts:描述C++ API接口行为,如接口名、入参、返回参数等。
    • src > main > cpp > types > libentry> oh-package.json5:配置.so三方包声明文件的入口及包名。
    • src > main > cpp > CMakeLists.txt:CMake配置文件,提供CMake构建脚本。
    • src > main > cpp > napi_init.cpp:定义C++ API接口的文件。
    • src > main > ets:用于存放ArkTS源码。
    • src > main > resources:用于存放应用所用到的资源文件,如图形、多媒体、字符串、布局文件等。

17.2 Native 代码开发

HarmonyOS NDK提供多个开放能力库,如图形图像、内存管理、设备管理等,供开发者实现代码逻辑;同时提供业界标准库,如libc标准库、标准C++库、Node-API等。

17.2.1 C/C++标准库

  1. C++ 兼容性

系统库和应用Native库依赖的C++标准库分别随系统镜像和编译SDK版本升级,跨版本会有ABI兼容性问题。系统库用libc++.so(命名空间__h),应用Native库用libc++_shared.so(命名空间__n1),二者不能混用。使用共享库HAR包构建应用时版本不同可能不兼容,可用相同SDK更新HAR包解决。已知问题如API9及之前SDK的 libc++_shared.so无__emutls_get_address符号,需更新SDK版本。

  1. musl libc动态链接器
  • 动态库加载命名空间隔离:设计目的是管控native库资源访问,动态链接器加载共享库需关联具体命名空间(ns)。HarmonyOS有default ns(搜索系统目录 so)、ndk ns(搜索 NDK 接口 so)、app ns(搜索应用安装路径 so)。default ns和ndk ns 可互访,不能访问app ns;app ns能访问ndk ns,不能访问default ns。
  • rpath机制:运行时指定共享库搜索路径,可解决应用多native库加载路径管理问题。
  • 其他特性:支持dlclose真实卸载动态库;支持symbol - version机制解决符号重定位和重复符号问题;网络接口select支持fd fortify检测;自API12起部分接口支持特定locale设置及相关函数,strtod_l和wcstod_l 不支持十六进制转换。

17.2.2 使用Node-API实现跨语言交互

使用 Node - API实现跨语言交互,需依其机制完成模块注册和加载等操作。ArkTS/JS侧通过 import 对应so库调用C++方法,Native侧在.cpp文件中实现模块注册,要提供lib库名并在注册回调中定义接口映射关系。

以实现add ()(ArkTS/JS 侧)和Add ()(Native 侧)接口为例,介绍流程:

  1. Native 侧方法实现

设置模块注册信息:ArkTS侧import native模块加载so时,调用napi_module_register方法,涉及napi_module 的.nm_register_func(模块初始化函数)和.nm_modname(模块名,即ArkTS侧引入so库名)两个关键属性。创建工程后相关代码已在napi_init.cpp配置好。

// entry/src/main/cpp/napi_init.cpp
// 准备模块加载相关信息,将上述Init函数与本模块名等信息记录下来。
static napi_module demoModule = {
    .nm_version = 1,
    .nm_flags = 0,
    .nm_filename = nullptr,
    .nm_register_func = Init,
    .nm_modname = "entry",
    .nm_priv = nullptr,
    .reserved = {0},
};

// 加载so时,该函数会自动被调用,将上述demoModule模块注册到系统中。
extern "C" __attribute__((constructor)) void RegisterDemoModule() { 
    napi_module_register(&demoModule);
}

在napi_init.cpp中实现ArkTS与C++ 接口的绑定和映射,在exports对象上挂载Native方法,在index.d.ts提供JS侧接口方法,在oh-package.json5将index.d.ts与cpp文件关联,在CMakeLists.txt配置打包参数,同时实现CallNative和NativeCallArkTS接口。

以下代码是模块初始化函数Init,用于将ArkTS 接口与C++接口进行绑定和映射,通过napi_define_properties函数在exports对象上挂载CallNative和NativeCallArkTS两个Native方法。

// entry/src/main/cpp/napi_init.cpp
EXTERN_C_START
// 模块初始化
static napi_value Init(napi_env env, napi_value exports) {
    // ArkTS接口与C++接口的绑定和映射
    napi_property_descriptor desc[] = {
        // 注:仅需复制以下两行代码,Init在完成创建Native C++工程以后在napi_init.cpp中已配置好。
        {"callNative", nullptr, CallNative, nullptr, 
            nullptr, nullptr, napi_default, nullptr},
        {"nativeCallArkTS", nullptr, NativeCallArkTS, 
            nullptr, nullptr, nullptr, napi_default, nullptr}
    };
    // 在exports对象上挂载CallNative/NativeCallArkTS两个Native方法
    napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
    return exports;
}
EXTERN_C_END

以下代码在index.d.ts文件中提供了JS侧的接口方法,定义了callNative和nativeCallArkTS两个函数的类型。

// entry/src/main/cpp/types/libentry/index.d.ts
export const callNative: (a: number, b: number) => number;
export const nativeCallArkTS: (cb: (a: number) => number) => number;

以下代码在oh-package.json5文件中将index.d.ts与cpp文件关联起来,指定了模块的名称、类型文件路径等信息。

// entry/src/main/cpp/types/libentry/oh-package.json5
{
  "name": "libentry.so",
  "types": "./index.d.ts",
  "version": "",
  "description": "Please describe the basic information."
}

以下代码是CMakeLists.txt文件,用于配置CMake打包参数,包括设置CMake的最低版本、项目名称、包含目录、添加库以及指定链接的库等。

# entry/src/main/cpp/CMakeLists.txt
cmake_minimum_required(VERSION 3.4.1)
    project(MyApplication2)

    set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})

    include_directories(${NATIVERENDER_ROOT_PATH}
                    ${NATIVERENDER_ROOT_PATH}/include)

    # 添加名为entry的库
    add_library(entry SHARED napi_init.cpp)
    # 构建此可执行文件需要链接的库
    target_link_libraries(entry PUBLIC libace_napi.z.so)

以下代码实现了Native侧的CallNative 接口,该接口接收两个参数,将它们相加并返回结果。

// entry/src/main/cpp/napi_init.cpp
static napi_value CallNative(napi_env env, napi_callback_info info)
{
    size_t argc = 2;
    // 声明参数数组
    napi_value args[2] = {nullptr};

    // 获取传入的参数并依次放入参数数组中
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    // 依次获取参数
    double value0;
    napi_get_value_double(env, args[0], &value0);
    double value1;
    napi_get_value_double(env, args[1], &value1);

    // 返回两数相加的结果
    napi_value sum;
    napi_create_double(env, value0 + value1, &sum);
    return sum;
}

以下代码实现了Native侧的NativeCallArkTS接口,该接口接收一个回调函数作为参数,创建一个整数作为ArkTS 的入参,调用传入的回调函数并返回其结果。

static napi_value NativeCallArkTS(napi_env env, napi_callback_info info)
{    
    size_t argc = 1;
    // 声明参数数组
    napi_value args[1] = {nullptr};

    // 获取传入的参数并依次放入参数数组中
    napi_get_cb_info(env, info, &argc, args , nullptr, nullptr);

    // 创建一个int,作为ArkTS的入参
    napi_value argv = nullptr;    
    napi_create_int32(env, 2, &argv );

    // 调用传入的callback,并将其结果返回
    napi_value result = nullptr;
    napi_call_function(env, nullptr, args[0], 1, &argv, &result);
    return result;
}
  1. ArkTS侧调用C/C++方法实现

ArkTS侧通过import引入Native侧包含处理逻辑的so来使用C/C++方法,如在Index.ets文件中引入并调用相关方法。以下代码在ArkTS的Index组件中,通过import引入Native能力,定义了两个文本组件,分别用于调用callNative和nativeCallArkTS方法,并显示调用结果。

// entry/src/main/ets/pages/Index.ets
// 通过import的方式,引入Native能力。
import nativeModule from 'libentry.so'

@Entry
  @Component
  struct Index {
    @State message: string = 'Test Node-API callNative result: ';
    @State message2: string = 'Test Node-API nativeCallArkTS result: ';
    build() {
      Row() {
        Column() {
          // 第一个按钮,调用add方法,对应到Native侧的CallNative方法,进行两数相加。
          Text(this.message)
            .fontSize(50)
            .fontWeight(FontWeight.Bold)
            .onClick(() => {
              this.message += nativeModule.callNative(2, 3);
            })
          // 第二个按钮,调用nativeCallArkTS方法,对应到Native的NativeCallArkTS,在Native调用ArkTS function。
          Text(this.message2)
            .fontSize(50)
            .fontWeight(FontWeight.Bold)
            .onClick(() => {
              this.message2 += nativeModule.nativeCallArkTS((a: number)=> {
                return a * 2;
              });
            })
        }
        .width('100%')
      }
      .height('100%')
    }
  }
  1. 约束限制
  • SO命名规则:导入与注册模块名大小写一致,如模块名entry,so名为libentry.so,相关字段设置和ArkTS 侧引用需对应。
  • 注册建议:nm_register_func对应函数(如 Init)加static防符号冲突,模块注册入口函数名(如 RegisterDemoModule)需确保唯一。
  • 多线程限制:每个引擎实例对应一个JS线程,对象不能跨线程操作,Node-API接口和Native接口入参env都只能在 JS 线程使用。

17.3 案例实战

本节运用前面学过的知识,实现音频低延时录制与播放。

17.3.1 实现音频低时延录制与播放

本示例基于OH_Audio的能力,在Native侧实现了音频低时延录制和播放功能,其中录制使用了音频采集器AudioCapturer,播放使用音频渲染器AudioRenderer,参考本示例可学习OH_Audio的使用,帮助应用开发音频录制与播放场景。

  • 弹出麦克风权限访问提示框,点击“允许”,如果点击"禁止"则不可进行录制,需要用户去设置页面给应用授权后方可正常录制
  • 在主界面点击“录制和播放”,进入音频录制界面,音频录制界面默认是普通录制界面,打开低时延录制开关可进行低时延录制
  • 点击录制按钮,开始录制,开始录制后低时延录制开关变为不可点击状态,录音时间开始计时,5s内不允许结束,30s后会自动结束录制
  • 点击暂停按钮,暂停录制,录音时间也停止计时
  • 点击继续按钮,继续录制,录音时间继续计时
  • 停止录制后,会生成录制结果,界面上有一个低时延播放开关和录制成功的音频播放器,点击低时延播放开关可打开低时延播放功能,点击播放可听到录制的音频,播放未结束之前低时延播放开关为不可点击状态
  • 点击返回按按钮回到主页

17.3.1.1 案例效果截图

17.3.1.2 案例运用到的知识点

  1. 核心知识点
  • 基于OpenHarmony平台的音频处理模块,主要实现了音频的录制和播放功能。
  1. 其他知识点
  • ArkTS语言基础
  • C++语言基础
  • Node-API
  • V2版状态管理:@ComponentV2/@Local/
  • Stage模型
  • 自定义组件和组件生命周期
  • @Builder装饰器:自定义构建函数
  • @Extend装饰器:定义扩展组件样式
  • if/else:条件渲染
  • 内置组件:Column/Row/Text/Image/Button/Progress
  • 日志管理类的编写
  • 常量与资源分类的访问
  • @ohos.promptAction (弹窗)
  • MVVM模式

17.3.1.3 代码结构解读

├──entry/src/main/cpp/  
│  ├──types
│  │  └──libentry
│  │     ├──index.d.ts                              // 接口导出
│  │     └──oh-package.json5                        // 配置管理文件
│  ├──AudioRecording.cpp                            // 调用native接口
│  └──CMakeLists.txt                                // 编译脚本
├──entry/src/main/ets/         
│  ├──constants
│  │  └──CommonConstants.ets                        // 常量类                     
│  ├──entryability
│  │  └──EntryAbility.ets                           // 程序入口类
│  ├──pages
│  │  └──Index.ets                                  // 主页入口
│  ├──utils
│  │  └──Logger.ets                                 // 日志类
│  └──view
│     └──AudioRecording.ets                         // 音频录制与播放自定义组件
└──entry/src/main/resource                          // 应用静态资源目录

17.3.1.4 录音模块实现

  1. 头文件包含
  • :标准库头文件,用于处理字符串。
  • <sys/stat.h>:用于获取文件状态信息,如文件大小。
  • napi/native_api.h:N-API 的头文件,用于创建 Node.js 原生插件。
  • ohaudio/ 相关头文件:自定义的音频处理库头文件,提供音频捕获、渲染和流构建等功能。
#include <string>
#include <sys/stat.h>
#include "napi/native_api.h"
#include "ohaudio/native_audiocapturer.h"
#include "ohaudio/native_audiorenderer.h"
#include "ohaudio/native_audiostreambuilder.h"
#include "ohaudio/native_audiostream_base.h"

2. 全局常量和变量

  • GLOBAL_RESMGR 和 TAG:全局常量,可能用于资源管理或日志标记。
  • AudioTestConstants 命名空间:包含一些音频测试相关的常量,如参数索引、录制时间等。
  • g_filePath:音频文件的路径。
  • g_file:文件指针,用于操作音频文件。
  • g_readEnd:标记文件是否读取结束。
  • g_rendererLowLatency:标记音频渲染器是否使用低延迟模式。
  • g_samplingRate 和 g_channelCount:音频采样率和声道数。
  • audioCapturer、audioRenderer、builder 和 rendererBuilder:音频捕获器、渲染器和流构建器的指针。
const int GLOBAL_RESMGR = 0xFF00;
const char *TAG = "[Sample_audio]";

namespace AudioTestConstants {
    // ...
}

static std::string g_filePath = 
"/data/storage/el2/base/haps/entry/files/oh_test_audio.pcm";
FILE *g_file = nullptr;
bool g_readEnd = false;
bool g_rendererLowLatency = false;
int32_t g_samplingRate = 48000;
int32_t g_channelCount = 2;

static OH_AudioCapturer *audioCapturer;
static OH_AudioRenderer *audioRenderer;
static OH_AudioStreamBuilder *builder;
static OH_AudioStreamBuilder *rendererBuilder;
  1. 获取状态信息的函数

这些函数用于获取音频渲染器、捕获器的状态,文件状态,低延迟模式状态,已写入的帧数和文件大小,并将结果封装成 N-API 值返回给 JavaScript 调用者。

static napi_value GetRendererState(napi_env env, napi_callback_info info);
static napi_value GetCapturerState(napi_env env, napi_callback_info info);
static napi_value GetFileState(napi_env env, napi_callback_info info);
static napi_value GetFastState(napi_env env, napi_callback_info info);
static napi_value GetFramesWritten(napi_env env, napi_callback_info info);
static napi_value GetFileSize(napi_env env, napi_callback_info info);
  1. 音频回调函数

AudioCapturerOnReadData:音频捕获器读取数据时的回调函数,将捕获的数据写入文件。

AudioRendererOnWriteData:音频渲染器写入数据时的回调函数,从文件中读取数据到缓冲区。

static int32_t AudioCapturerOnReadData(
OH_AudioCapturer *capturer, void *userData, void *buffer, int32_t bufferLen);
static int32_t AudioRendererOnWriteData(
OH_AudioRenderer *renderer, void *userData, void *buffer, int32_t bufferLen);
  1. 音频捕获和渲染的初始化、控制函数

这些函数用于初始化音频捕获器和渲染器,设置参数和回调函数,启动、暂停、停止和释放音频设备。

static napi_value AudioCapturerLowLatencyInit(napi_env env, 
napi_callback_info info);
static napi_value AudioCapturerInit(napi_env env, napi_callback_info info);
static napi_value AudioCapturerStart(napi_env env, napi_callback_info info);
static napi_value AudioCapturerPause(napi_env env, napi_callback_info info);
static napi_value AudioCapturerStop(napi_env env, napi_callback_info info);
static napi_value AudioCapturerRelease(napi_env env, napi_callback_info info);

static napi_value AudioRendererLowLatencyInit(napi_env env, 
napi_callback_info info);
static napi_value AudioRendererInit(napi_env env, napi_callback_info info);
static napi_value AudioRendererStart(napi_env env, napi_callback_info info);
static napi_value AudioRendererPause(napi_env env, napi_callback_info info);
static napi_value AudioRendererStop(napi_env env, napi_callback_info info);
static napi_value AudioRendererRelease(napi_env env, napi_callback_info info);
  1. 关闭文件函数

用于关闭打开的音频文件。

static napi_value CloseFile(napi_env env, napi_callback_info info);
  1. 模块初始化和注册
  • Init 函数:用于初始化 Node.js 模块,将所有的 C++ 函数封装成 JavaScript 方法,并添加到 exports 对象中。
  • demoModule:定义了一个 N-API 模块结构体,包含模块的版本、名称、注册函数等信息。
  • RegisterEntryModule 函数:在模块加载时自动调用,注册该模块。
EXTERN_C_START static napi_value Init(napi_env env, napi_value exports);
EXTERN_C_END

static napi_module demoModule = {
    .nm_version = 1,
    .nm_flags = 0,
    .nm_filename = nullptr,
    .nm_register_func = Init,
    .nm_modname = "entry",
    .nm_priv = ((void *)0),
    .reserved = {0},
};

extern "C" __attribute__((constructor)) void RegisterEntryModule(void) { napi_module_register(&demoModule); }

17.3.1.5 声明Arkts可调用的类型文件

这段代码定义了一系列的 TypeScript 函数类型声明,这些声明对应了前面 C++ 代码中通过 N-API 封装成 Node.js 模块的各种音频操作和状态获取函数。通过这些类型声明,开发者在使用这些函数时可以获得更好的类型检查和代码提示

export const audioCapturesInit: () => void;
export const audioCapturesLowLatencyInit: () => void;
export const audioCapturesStart: () => void;
export const audioCapturesPause: () => void;
export const audioCapturesStop: () => void;
export const audioCapturesRelease: () => void;
export const audioRendererInit: () => void;
export const audioRendererLowLatencyInit: () => void;
export const audioRendererStart: () => void;
export const audioRendererPause: () => void;
export const audioRendererStop: () => void;
export const audioRendererRelease: () => void;
export const getRendererState: () => number;
export const getCapturerState: () => number;
export const getFileState: () => number;
export const getFastState: () => number;
export const getFramesWritten: () => number;
export const getFileSize: () => number;
export const closeFile: () => number;

17.3.16 编写Arkts模块实现页面调用

AudioRecording组件主要实现了音频录制、暂停、继续、停止以及播放等操作,同时允许用户选择低延迟模式进行音频捕获和渲染。组件会在不同的操作状态下展示相应的界面,并实时更新录制和播放的时间信息。

  1. 模块导入

代码开头导入了多个模块,这些模块提供了不同的功能支持:

@kit.AbilityKit中的abilityAccessCtrl和common,用于权限管理和获取 UI 能力上下文。

@kit.AudioKit中的audio,提供音频相关的功能和状态定义。

@kit.BasicServicesKit中的BusinessError和deviceInfo,用于错误处理和获取设备信息。

libentry.so作为 Node.js 原生插件,封装了底层的音频捕获和渲染功能。

CommonConstants包含了项目中使用的各种常量,如状态值、时间间隔等。

logger用于记录日志,方便调试和错误追踪。

  1. 组件状态管理

组件中定义了多个状态变量,使用@State装饰器使其成为响应式状态,当这些变量的值发生变化时,组件的 UI 会自动更新:

recordState:记录音频录制的状态,如初始、开始、暂停、继续、停止等。

title:音频文件的标题。

date:录制日期。

playSec:播放的秒数。

renderState:音频渲染器的状态。

recordSec:录制的秒数。

showTime:显示的时间信息。

isRecordOver:标记录制是否结束。

此外,还有一些私有变量用于控制音频捕获和渲染的低延迟模式。

  1. 生命周期方法

aboutToAppear:在组件即将显示时调用initResource方法,进行资源的初始化,如设置录制状态和获取当前日期。

aboutToDisappear:在组件即将消失时调用releaseResource方法,释放资源,包括清除定时器、释放音频捕获和渲染资源以及关闭文件。

  1. 界面构建

代码中定义了多个@Builder装饰的方法,用于构建不同状态下的界面:

InitRecord:初始状态界面,包含一个开始录制的按钮。点击按钮后,会请求麦克风权限,根据用户选择的模式初始化音频捕获器并启动录制。

StartedRecord:录制进行中界面,显示录制时间,提供停止和暂停按钮,用于控制录制过程。

PausedRecord:录制暂停界面,同样显示录制时间,提供停止和继续按钮,方便用户继续录制。

FinishedRecord:录制完成界面,显示一个灰色的录制图标,表示录制已结束。

  1. 主构建方法

build方法是组件的主构建逻辑,根据不同的状态渲染相应的界面。同时,提供了音频捕获和渲染的低延迟模式配置选项,使用Toggle开关控制。当录制完成后,会显示录制结果信息,包括标题、日期、录制时长、播放进度条等,并允许用户进行播放和暂停操作。

  1. 资源管理

initResource:初始化录制状态和日期,确保组件在开始时处于正确的状态。

releaseResource:在组件销毁时,清除定时器,释放音频捕获和渲染资源,关闭文件,避免资源泄漏。

  1. 音频操作方法

capturesStart:启动音频捕获,重置录制时间,开始计时,并更新录制状态为开始。

renderCreate:创建音频渲染器,并获取其当前状态。

renderStart:启动音频渲染,实时更新播放进度,根据已写入的帧数和文件大小计算播放时间。当文件读取结束时,停止渲染并进行相应处理。

renderPause:暂停音频渲染,清除定时器,更新渲染状态。

capturesContinue:继续音频捕获,继续计时,并更新录制状态为继续。

capturesStop:停止音频捕获,释放资源,关闭文件,更新录制状态为停止,并创建音频渲染器以便后续播放。

capturesPause:暂停音频捕获,清除定时器,更新录制状态为暂停。

  1. 辅助方法

formatNumber:将数字格式化为两位数的字符串,不足两位时前面补 0。

getDate:根据传入的模式返回不同格式的日期字符串。

getTimesBySecond:将秒数转换为HH:MM:SS格式的时间字符串,方便在界面上显示。


import { abilityAccessCtrl, common } from '@kit.AbilityKit';
import { audio } from '@kit.AudioKit';
import { BusinessError, deviceInfo } from '@kit.BasicServicesKit';
import testNapi from 'libentry.so';
import CommonConstants from '../constants/CommonConstants';
import { logger } from '../utils/Logger';

let context = getContext(this) as common.UIAbilityContext;
let atManager = abilityAccessCtrl.createAtManager();

@Component
  export struct AudioRecording {
    /**
   * [init,started,continued,paused,stopped]
   */
    @State recordState: string = CommonConstants.PLAY_INIT;
    @State title: string = 'oh_test_audio';
    @State date: string = '';
    @State playSec: number = 0;
    @State renderState: number = 0;
    @State recordSec: number = 0;
    @State showTime: string = '00:00:00';
    @State isRecordOver: boolean = false;
    private audioCapturesLowLatency: boolean = false;
    private audioCaptures: boolean = false;
    private audioRendererLowLatency: boolean = false;
    private interval: number = 0;

    aboutToAppear() {
      this.initResource();
    }

    aboutToDisappear() {
      this.releaseResource();
    }

    @Builder
    InitRecord() {
      Column() {
        Image($r('app.media.ic_record'))
          .width($r('app.float.record_width'))
          .height($r('app.float.record_width'))
      }
      .width(CommonConstants.FULL_PERCENT)
        .height($r('app.float.common_title'))
        .id('start_record_btn')
        .onClick(() => {
          atManager.requestPermissionsFromUser(context,
                                               ['ohos.permission.MICROPHONE']).then((data) => {
            if (data.authResults[0] !== 0) {
              return;
            }
            if (this.audioCapturesLowLatency) {
              testNapi.audioCapturesLowLatencyInit();
            }
            if (this.audioCaptures || 
                (!this.audioCapturesLowLatency && !this.audioCaptures)) {
              testNapi.audioCapturesInit();
            }
            this.capturesStart();
          }).catch((err: BusinessError) => {
            logger.error('error:' + JSON.stringify(err));
          });
        })
    }

    @Builder
    StartedRecord() {
      Column() {
        Text(this.showTime)
          .fontSize($r('app.float.time_size'))
          .fontWeight(CommonConstants.FONT_WIGHT_500)
          .margin({ bottom: $r('app.float.time_margin') })
      }
      .width(CommonConstants.FULL_PERCENT)
        .height($r('app.float.start_height'))
        .position({ y: $r('app.float.start_position') })
        .id('show_time_txt')

      Column() {
        Image($r('app.media.ic_recording'))
          .width($r('app.float.record_width'))
          .height($r('app.float.record_width'))
      }
      .width(CommonConstants.FULL_PERCENT)
        .height($r('app.float.common_title'))
        .position({ y: $r('app.float.start_record_position') })
        .id('stop_record_btn')
        .onClick(() => {
          this.capturesStop();
        })

      Column() {
        Image($r('app.media.ic_record_pause'))
          .width($r('app.float.common_image'))
          .height($r('app.float.common_image'))
        Text($r('app.string.pause_audio'))
          .fontSize($r('app.float.small_size'))
        .id('pause_record_btn')
        .margin({ top: $r('app.float.pause_margin_top') })
    }
    .height($r('app.float.common_title'))
    .width($r('app.float.common_title'))
    .position({
      x: CommonConstants.PAUSE_POSITION_X,
      y: $r('app.float.start_record_position')
    })
    .alignItems(HorizontalAlign.Center)
    .justifyContent(FlexAlign.Center)
    .onClick(() => {
      this.capturesPause();
    })
  }

  @Builder
  PausedRecord() {
    Column() {
      Text(this.showTime)
        .fontSize($r('app.float.time_size'))
        .fontWeight(CommonConstants.FONT_WIGHT_500)
        .margin({ bottom: $r('app.float.time_margin') })
    }
    .width(CommonConstants.FULL_PERCENT)
    .height($r('app.float.start_height'))
    .position({ y: $r('app.float.start_position') })

    Column() {
      Image($r('app.media.ic_recording'))
        .width($r('app.float.record_width'))
        .height($r('app.float.record_width'))
    }
    .width(CommonConstants.FULL_PERCENT)
    .height($r('app.float.common_title'))
    .position({ y: $r('app.float.start_record_position') })
    .onClick(() => {
      this.capturesStop();
    })

    Column() {
      Image($r('app.media.ic_record_continue'))
        .width($r('app.float.common_image'))
        .height($r('app.float.common_image'))
      Text($r('app.string.CONTINUE'))
        .fontSize($r('app.float.small_size'))
        .margin({ top: $r('app.float.pause_margin_top') })
    }
    .height($r('app.float.common_title'))
    .width($r('app.float.common_title'))
    .position({
      x: CommonConstants.PAUSE_POSITION_X,
      y: $r('app.float.start_record_position')
    })
    .alignItems(HorizontalAlign.Center)
    .justifyContent(FlexAlign.Center)
    .id('continue_record_btn')
    .onClick(() => {
      this.capturesContinue();
    })
  }

  @Builder
  FinishedRecord() {
    Column() {
      Image($r('app.media.ic_record'))
        .width($r('app.float.record_width'))
        .height($r('app.float.record_width'))
    }
    .width(CommonConstants.FULL_PERCENT)
    .height($r('app.float.common_title'))
    .position({ y: $r('app.float.start_record_position') })
    .opacity(CommonConstants.AUDIO_OPACITY)
  }

  build() {
    NavDestination() {
      Column() {
        Column() {
          Row() {
            Text($r('app.string.low_latency_captures'))
              .fontSize($r('app.float.common_font'))
              .fontWeight(CommonConstants.FONT_WIGHT_500)
              .fontColor($r('sys.color.font_primary'))
              .fontFamily($r('sys.string.ohos_id_text_font_family_medium'))
              .opacity(this.isRecordOver ? CommonConstants.AUDIO_OPACITY : 1)
            Toggle({
              type: ToggleType.Switch,
              isOn: this.audioCapturesLowLatency
            })
              .onChange((isOn: boolean) => {
                if (isOn) {
                  this.audioCaptures = false;
                  this.audioCapturesLowLatency = true;
                } else {
                  this.audioCaptures = true;
                  this.audioCapturesLowLatency = false;
                }
              })
              .enabled(this.recordState === CommonConstants.PLAY_INIT &&
                deviceInfo.deviceType === CommonConstants.DEVICE_PHONE)
          }
          .width(CommonConstants.FULL_PERCENT)
          .justifyContent(FlexAlign.SpaceBetween)
        }
        .width(CommonConstants.FULL_PERCENT)
        .height($r('app.float.common_title'))
        .backgroundColor(Color.White)
        .borderRadius($r('app.float.border_radius'))
        .margin({ top: $r('app.float.small_margin') })
        .justifyContent(FlexAlign.Center)
        .padding({
          left: $r('app.float.small_padding'),
          right: $r('app.float.small_padding')
        })

        if (this.isRecordOver === true) {
          Column() {
            Row() {
              Text($r('app.string.low_latency_renderer'))
                .fontSize($r('app.float.common_font'))
                .fontWeight(CommonConstants.FONT_WIGHT_500)
                .fontColor($r('sys.color.font_primary'))
                .id('renderer_low_latency_btn')
                .onChange((isOn: boolean) => {
                  if (isOn) {
                    testNapi.audioRendererLowLatencyInit();
                    this.audioRendererLowLatency = true;
                  } else {
                    testNapi.audioRendererInit();
                    this.audioRendererLowLatency = false;
                  }
                })
                .enabled(this.renderState === 
                    audio.AudioState.STATE_PREPARED && 
                    deviceInfo.deviceType === 'phone')
            }
            .width(CommonConstants.FULL_PERCENT)
            .justifyContent(FlexAlign.SpaceBetween)
          }
          .width(CommonConstants.FULL_PERCENT)
          .height($r('app.float.common_title'))
          .backgroundColor(Color.White)
          .justifyContent(FlexAlign.Center)
          .borderRadius($r('app.float.border_radius'))
          .margin({ top: $r('app.float.small_margin') })
          .padding({
            left: $r('app.float.small_padding'),
            right: $r('app.float.small_padding')
          })

          Column() {
            Text($r('app.string.record_result'))
              .fontSize($r('app.float.result_size'))
              .opacity(CommonConstants.COMMON_OPACITY)
              .position({ x: 0 })
          }
          .height($r('app.float.result_height'))
          .width(CommonConstants.FULL_PERCENT)
          .margin({ top: $r('app.float.common_padding') })

          Column() {
            Row() {
              Text(this.title)
                .fontSize($r('app.float.common_font'))
                .fontWeight(CommonConstants.FONT_WIGHT_500)
                .fontColor($r('sys.color.font_primary'))
                .width($r('app.float.common_image'))
                .height($r('app.float.common_image'))
                .id('playing_state')
            }
            .width(CommonConstants.FULL_PERCENT)
            .height($r('app.float.common_height'))
            .justifyContent(FlexAlign.SpaceBetween)

            Row() {
              Text(this.date)
                .fontSize($r('app.float.common_font'))
                .fontColor($r('sys.color.font_primary'))
                .opacity(CommonConstants.COMMON_OPACITY)
                
              Text(this.getTimesBySecond(this.recordSec) + '')
                .fontSize($r('app.float.common_font'))
                .fontColor($r('sys.color.font_primary'))
                .opacity(CommonConstants.COMMON_OPACITY)
            }
            .width(CommonConstants.FULL_PERCENT)
            .height($r('app.float.common_height'))
            .justifyContent(FlexAlign.SpaceBetween)
            .margin({
              top: $r('app.float.time_margin_top'),
              bottom: $r('app.float.small_margin')
            })

            Row() {
              Text($r('app.string.play_now'))
                .fontSize($r('app.float.small_size'))
                .fontFamily($r('sys.string.ohos_id_text_font_family_medium'))
                .fontColor($r('sys.color.font_primary'))
                .opacity(CommonConstants.COMMON_OPACITY)
              Text(this.getTimesBySecond(this.playSec) + '')
                .fontSize($r('app.float.small_size'))
                .fontFamily($r('sys.string.ohos_id_text_font_family_medium'))
                .fontColor($r('sys.color.font_primary'))
                .opacity(CommonConstants.COMMON_OPACITY)
            }
            .justifyContent(FlexAlign.SpaceBetween)
            .width(CommonConstants.FULL_PERCENT)

            Row() {
              Progress({ value: this.playSec, 
                  total: this.recordSec, type: ProgressType.Linear })
                .value(this.playSec)
                .width(CommonConstants.FULL_PERCENT)
            }
            .margin({
              top: $r('app.float.time_margin_top')
            })
          }
          .width(CommonConstants.FULL_PERCENT)
          .backgroundColor(Color.White)
          .borderRadius($r('app.float.border_radius'))
          .margin({ top: $r('app.float.play_margin_top') })
          .padding({
            left: $r('app.float.small_padding'),
            right: $r('app.float.small_padding'),
            top: $r('app.float.common_padding'),
            bottom: $r('app.float.common_padding')
          })
          .id('player_btn')
          .onClick(() => {
            if (this.renderState === audio.AudioState.STATE_PREPARED) {
              this.renderStart();
            } else if (this.renderState === audio.AudioState.STATE_RUNNING) {
              this.renderPause();
            } else if (this.renderState === audio.AudioState.STATE_PAUSED) {
              this.renderStart();
            } else if (this.renderState === audio.AudioState.STATE_STOPPED) {
              this.renderStart();
            }
          })
        }
        Row() {
          if (this.recordState === CommonConstants.PLAY_INIT) {
            this.InitRecord();
          } else if (this.recordState === CommonConstants.PLAY_STARTED) {
            this.StartedRecord();
          } else if (this.recordState === CommonConstants.PLAY_PAUSED) {
            this.PausedRecord();
          } else if (this.recordState === CommonConstants.PLAY_CONTINUED) {
            this.StartedRecord();
          } else if (this.recordState === CommonConstants.PLAY_STOPPED) {
            this.FinishedRecord();
          }
        }
        .width(CommonConstants.FULL_PERCENT)
        .alignItems(VerticalAlign.Center)
        .height($r('app.float.row_height'))
        .position({ y: CommonConstants.RECORD_POSITION_Y })
      }
      .width(CommonConstants.FULL_PERCENT)
      .height(CommonConstants.FULL_PERCENT)
      .justifyContent(FlexAlign.Start)
      .padding($r('app.float.border_radius'))
    }
    .width(CommonConstants.FULL_PERCENT)
    .height(CommonConstants.FULL_PERCENT)
    .title(getContext(this)
            .resourceManager.getStringSync($r('app.string.audio_captures')))
    .backgroundColor($r('app.color.audio_background'))
  }

  initResource() {
    try {
      this.recordState = CommonConstants.PLAY_INIT;
      this.date = this.getDate(1);
    } catch (err) {
      logger
          .error(`AudioRecording:createAudioCapturer err = 
          ${JSON.stringify(err)}`);
    }
  }

  releaseResource() {
    if (this.interval) {
      clearInterval(this.interval);
    }
    testNapi.audioCapturesRelease();
    this.recordState = 'init';
    testNapi.audioRendererRelease();
    testNapi.closeFile();
  }

  capturesStart() {
    try {
      testNapi.audioCapturesStart();
      this.recordSec = 0;
      this.recordState = CommonConstants.PLAY_STARTED;
      clearInterval(this.interval);
      this.interval = setInterval(async () => {
        if (this.recordSec >= CommonConstants.TOTAL_SECOND) {
          clearInterval(this.interval);
          this.capturesStop();
          return;
        }
        this.recordSec++;
        this.showTime = this.getTimesBySecond(this.recordSec);
      }, CommonConstants.INTERVAL_TIME);
    } catch (err) {
      logger.error(`AudioRecording:audioCapturer start err = 
                ${JSON.stringify(err)}`);
    }
  }

  renderCreate() {
    try {
      testNapi.audioRendererInit();
      this.renderState = testNapi.getRendererState();
    } catch (err) {
      logger.error(`createAudioRenderer err = ${JSON.stringify(err)}`);
    }
  }

  renderStart() {
    try {
      testNapi.audioRendererStart();
      this.renderState = testNapi.getRendererState();
      if (this.playSec === this.recordSec) {
        this.playSec = 0;
      }
      this.interval = setInterval(async () => {
        let playNumber = Math.round(testNapi.getFramesWritten() * 4 / testNapi.getFileSize() * this.recordSec);
        this.playSec = 
            (playNumber < 0 || this.playSec === this.recordSec) ? 
            this.recordSec : playNumber;
        if (testNapi.getFileState()) {
          testNapi.audioRendererStop();
          testNapi.audioRendererRelease();
          testNapi.closeFile();
          if (testNapi.getFastState()) {
            testNapi.audioRendererLowLatencyInit();
          } else {
            testNapi.audioRendererInit();
          }
          clearInterval(this.interval);
          this.renderState = testNapi.getRendererState();
          return;
        }
      }, 50);
    } catch (err) {
      logger.error(`write err:${JSON.stringify(err)}`);
    }
  }

  renderPause() {
    try {
      testNapi.audioRendererPause();
      this.renderState = testNapi.getRendererState();
      clearInterval(this.interval);
    } catch (err) {
      logger.error(`pause err: ${JSON.stringify(err)}`);
    }
  }

  capturesContinue() {
    try {
      testNapi.audioCapturesStart();
      this.recordState = CommonConstants.PLAY_CONTINUED;
      logger.info('audioCaptures start ok');
      this.interval = setInterval(async () => {
        if (this.recordSec >= CommonConstants.TOTAL_SECOND) {
          clearInterval(this.interval);
          this.capturesStop();
          return;
        }
        this.recordSec++;
        this.showTime = this.getTimesBySecond(this.recordSec);
      }, CommonConstants.INTERVAL_TIME);
    } catch (err) {
      logger.error(`AudioRecording:audioCapturer start err = ${JSON.stringify(err)}`);
    }
  }

  capturesStop() {
    if (this.recordSec < CommonConstants.MIN_RECORD_SECOND) {
      return;
    }
    try {
      testNapi.audioCapturesStop();
      testNapi.audioCapturesRelease();
      testNapi.closeFile();
      this.recordState = CommonConstants.PLAY_STOPPED;
      clearInterval(this.interval);
    } catch (err) {
      this.recordState = CommonConstants.PLAY_STOPPED;
      logger.error(`AudioRecording:audioCapturer stop err = 
          ${JSON.stringify(err)}`);
    }
    this.isRecordOver = true;
    this.renderCreate();
  }

  capturesPause() {
    try {
      testNapi.audioCapturesPause();
      this.recordState = CommonConstants.PLAY_PAUSED;
      clearInterval(this.interval);
    } catch (err) {
      logger.error(`AudioRecording:audioCapturer stop err 
          = ${JSON.stringify(err)}`);
    }
  }

  formatNumber(num: number) {
    if (num <= 9) {
      return '0' + num;
    } else {
      return '' + num;
    }
  }

  getDate(mode: number) {
    let date = new Date();
    if (mode === 1) {
      return `${date.getFullYear()} / ${this.formatNumber(date.getMonth() + 1)} / ${this.formatNumber(date.getDate())}`;
    } else {
      return `${date.getFullYear()}${this.formatNumber(date.getMonth() + 1)}${this.formatNumber(date.getDate())}`;
    }
  }

  getTimesBySecond(t: number) {
    let h = Math.floor(t / 60 / 60 % 24);
    let m = Math.floor(t / 60 % 60);
    let s = Math.floor(t % 60);
    let hs = h < 10 ? '0' + h : h;
    let ms = m < 10 ? '0' + m : m;
    let ss = s < 10 ? '0' + s : s;
    return `${hs}:${ms}:${ss}`;
  }
}