本文正在參加星光計劃3.0–夏日挑戰賽
作者:張志成
前言
NAPI(Native API)是OpenHarmony系統中的一套原生子產品擴充開發架構,它基于Node.js N-API規範開發,為開發者提供了JavaScript與C/C++子產品之間互相調用的互動能力。這套機制對于鴻蒙系統開發的價值有兩方面:
- 鴻蒙系統可以将架構層豐富的子產品功能通過js接口開放給上層應用使用。
- 應用開發者也可以選擇将一些對性能、底層系統調用有要求的核心功能用C/C++封裝實作,再通過js接口使用,提高應用本身的執行效率。
1. NAPI在系統中的位置
NAPI在OpenHarmony中屬于UI架構的一部分。
![](https://img.laitimes.com/img/_0nNw4CM6IyYiwiM6ICdiwiI2YDOfhGLwIDOfdHLlpXazVmcvwVZnFWbp1zczV2YvJHctM3cv1Ce-cmbw5iZzMWYwIzN2YDM4EGN2gzYyADOhFWO3UmYjRjM4gzN5IjNkFmN38CX3AjMyAjMvw1cldWYtl2Lc12bj5yb0NWM14ycvlnbv1mchhWLsR2Lc9CX6MHc0RHaiojIsJye.png)
實作一個NAPI子產品,開發者需要完成子產品注冊、定義接口映射、實作回調方法等工作,這些工作在NAPI架構内部是怎麼起作用的,為了實作Js與C++的互動,架構又做了哪些事情?今天我們就來看一看NAPI架構的内部實作。
2. NAPI架構代碼目錄結構
NAIP架構代碼在 foundation\arkui\napi\ 路徑下。總體上可分為interface、native_engine 和 xxxManager 三部分。
interface 目錄為NAPI開發者提供了各種常用功能的API接口及宏定義。
native_engine 目錄是NAPI架構的核心部分,interface目錄提供的接口對應的功能都在這裡實作。C++與Js之間的調用互動最終是要依托JS引擎來完成的,針對系統支援的不同JS引擎,在impl目錄中也有對應的4種實作(ark, jerryscript, quickjs, v8)。
此外,還有幾個Manager目錄,分别負責子產品注冊、引用對象管理、作用域管理等專項功能。
我們知道,一個子產品被設計成什麼樣,往往是由它面臨的問題決定的。為了了解這些目錄的各個組成部分發揮的作用,我們先來看看JS調用C++的過程中,NAPI架構需要解決哪些問題。
3. NAPI架構完成的主要工作
假設我們在架構層用C/C++實作了一個myapp子產品,這個子產品可以為應用提供系統通路次數的統計。為了讓應用層的JS代碼能夠使用這項能力,我們為應用開發者提供了如下的JS接口:
@ohos.myapp.d.ts
declare namespace myapp {
// 同步方法
function getVisitCountSync(key: string, defaultValue?: string): string;
// 異步方法
function getVisitCountAsync(key: string, callback: AsyncCallback<string>): void; // callback回調方式
function getVisitCountAsync(key: string, defaultValue?: string): Promise<string>; // Promise方式
}
App應用開發者的JS代碼簡單導入一下子產品就可以直接調用了。
import myapp1 from "@ohos.myapp"
var result = myapp1.getVisitCountSync("www.mywebsite.com");
為了實作這樣的調用,NAPI架構需要解決以下問題,各個子子產品在其中都發揮了相關作用。
1)子產品注冊(import的子產品名稱”myapp”,是怎麼對應到實際的c++lib庫的?) --- Module Manager
2)方法名映射(js調用的”getVisitCountSync”等方法,是怎麼對應到native的C/C++的方法的?) --- Native Engine
3)資料傳遞與轉換(js傳入的入參、得到的傳回結果,需要轉換成C/C++代碼可以操作的資料類型)--- NativeValue
而對于稍微再複雜一點的異步調用:
/** Promise 方式的異步調用 */
var promiseObj = myapp1.getVisitCountAsync("www.mywebsite.com").then(data => {
......
}).catch(error => {
......
});
/** call back 方式的異步調用 */
myapp1.getVisitCountAsync("www.mywebsite.com", function (err, ret) {
......
});
NAPI架構還需要解決以下問題:
4)異步執行(js的調用立刻得到傳回,native的業務處理另起線程單獨執行)--- NativeEngine -- AsnycWork
5)Callback實作(C/C++怎麼回調js提供的callback方法,傳回結果怎麼在異步線程中傳遞)--- Native Reference
6)Promise實作(NodeJs promise文法特性的實作)--- Native Deferred
3.1 NAPI架構背後依托的是JavaScript引擎
通過代碼目錄結構我們看到NAPI架構針對JerryScirpt、V8、QuickJS和鴻蒙自己的Ark引擎都單獨實作了一套native_engin impl。這是因為C++到Js的調用最終是要依托JS引擎提供的能力實作的。
例如,當NAPI的開發者需要建立一個能被js代碼識别的big int數值對象,建立的過程如下圖所示:
可以看到,最終建立這個數值對象的工作是由JS引擎去完成的,引擎從自己的GlobalStorage中建立了一個新的GlobalHandle來儲存這個數值。
4. NAPI子產品注冊功能的實作
開發一個NAPI子產品,首先需要按照NAPI架構的機制要求實作注冊相關動作,通過注冊告訴鴻蒙系統你開發的這個lib庫的名稱,提供了哪些native方法,以及它們對應的js接口名稱是什麼。
下圖為一個NAPI接口“add()"的實作,C++代碼中定義了lib庫對應的module名稱,并在注冊回調方法中定義了js方法和C++方法的名稱映射關系。
圖左側的JS應用代碼比較簡單,import一個C++的so庫,然後直接調用add()方法就可以了。我們比較關心的是,圖右側的C++代碼實際是如何起作用的?
4.1 注冊子產品
最先被執行的是RegisterModule方法
extern "C" __attribute__((constructor)) void RegisterModule(void)
{
napi_module_register(&demoModule);
}
這裡,NAPI開發者隻需要調用一下napi_module_register()方法即可完成注冊,進一步看它的内部實作,ModuleManager登場了,它有一個内部連結清單(firstNativeModule_,lastNativeModule),開發者傳入的demoModule注冊資訊最終是儲存到了連結清單尾部。
到這裡RegisterModule()的操作就結束了。感覺好像什麼事都沒幹啊?沒錯,這裡僅僅是做了個”登記“,真正加載動态庫、映射方法名稱的操作,要等到這個登記的module被js程式真正用到的時候。(通過import from xxx 或 requireNapi("xxx") 加載module)
4.2 加載子產品
子產品被注冊到ModuleManager後,什麼時候被加載使用?我們看一下Native_module_manager.h的定義
這個ModuleManager類隻提供了寥寥幾個對外接口,外部程式想擷取到它連結清單中的module對象,隻能通過LoadNativeModule()方法,應該是它沒錯了。
在鴻蒙架構代碼中四處尋覓LoadNativeModule()之後,我們在各個NativeEngine的構造函數中,都發現了它的蹤迹。用法大體相同。
這裡以ArkNativeEngine實作為例,繼續上代碼:
ArkNativeEngine在自己的構造函數中,定義了一個回調方法"requireNapi",當js應用程式調用requireNapi("xxxModule")時,上圖這段回調代碼将會被執行。(在鴻蒙架構的js代碼中搜requireNapi會發現很多這樣的調用)。
我們注意到回調方法裡做了2件事:
- loadNativeModule() --- 加載子產品(讀取動态庫)
- registerCallback() --- 執行開發者定義的注冊回調函數 (儲存js和c++方法名稱映射關系)
先看loadNativeModule() 内部實作,最終執行到NativeModuleManager::LoadModuleLibrary() 方法中,執行系統調用LoadLibrary(path)加載動态庫。
其中path的定義可以參見 NativeModuleManager::GetNativeModulePath() 方法
這就是鴻蒙的各種Native C++動态庫最終部署在目标裝置上的位置。
module被加載後,接着就是回調開發者自定義的注冊函數,在本例中就是開發者實作的 Init() 方法。這裡面調用了napi_define_properties(),把js方法"add"和C++方法"Add"的映射資訊以屬性的形式儲存到JS Runtime運作時中。
後續當APP應用程式調用js接口"add"時,JS Runtime就能通過映射關系屬性找到C++的"Add"方法引用并執行。
5. NAPI 方法實作
說完了子產品注冊流程,我們再來看看C++ Native方法的實作。
還是以前面提到的這組接口為例:
@ohos.myapp.d.ts
declare namespace myapp {
// 同步方法
function getVisitCountSync(key: string, defaultValue?: string): string;
// 異步方法
function getVisitCountAsync(key: string, callback: AsyncCallback<string>): void; // callback回調方式
function getVisitCountAsync(key: string, defaultValue?: string): Promise<string>; // Promise方式
}
這組接口為JS應用提供了一個擷取通路次數的功能,并提供了同步、異步兩種方法。其中異步方法的異步回調提供了callback和promise兩種方式,
callback方式是由使用者自定義一個回調函數,NAPI将執行結果通過回調函數的入參傳回給使用者;promise方式是NAPI傳回一個promise對象給使用者,後續使用者可以通過調用promise.then() 擷取傳回結果。
5.1 同步方法的實作
先看同步方法的C++實作。這個比較簡單,C++開發者做好資料的轉換工作就可以了。
Js調用傳遞的參數對象、函數對象都是以napi_value這樣一個抽象的類型提供給C++的,開發者需要将它們轉換為C++資料類型進行計算,再将計算結果轉為napi_value類型傳回就可以了。NAPI架構提供了各種api接口為使用者完成這些轉換。前面我們提到過,這些轉換工作背後是依賴JS引擎去實作的。
static napi_value GetVisitCountSync(napi_env env, napi_callback_info info) {
/* 根據環境變量擷取參數 */
size_t argc = 2; //參數個數
napi_value argv[2] = { 0 }; //參數定義
/* 入參變量擷取 */
napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
// 擷取入參的類型
napi_valuetype valueType = napi_undefined;
napi_typeof(env, argv[0], &valueType);
// 入參值轉換為C/C++可以操作的資料類型
char value[VALUE_BUFFER_SIZE] = { 0 };
size_t valueLen = 0;
napi_get_value_string_utf8(env, argv[0], value, VALUE_BUFFER_SIZE, &valueLen);
// ...... 省略若幹業務流程計算步驟
/* C/C++資料類型轉換為JS資料類型并傳回 */
napi_value result = nullptr; // JS字元串對象
std::string resultStr = "Visit Count = 65535";
napi_create_string_utf8(env, resultStr.c_str(), resultStr.length(), &result);
return result; //傳回JS對象
}
5.2 異步方法的實作
C++實作異步方法需要做這三件事:
1)立即傳回一個臨時結果給js調用者
2)另起線程完成異步計算工作
3)通過callback或promise傳回正真的計算結果
下面代碼給出了異步方法實作主體部分的注釋說明:
//異步方法需要在不同線程中傳遞各種業務資料,定義一個結構體儲存這些被傳遞的資訊
struct MyAsyncContext {
napi_env env = nullptr; // napi運作環境
napi_async_work work = nullptr; // 異步工作對象
napi_deferred deferred = nullptr; // 延遲執行對象(用于promise方式傳回計算結果)
napi_ref callbackRef = nullptr; // js callback function的引用對象 (用于callback方式傳回計算結果)
};
static napi_value GetVisitCountAsync(napi_env env, napi_callback_info info)
{
...... // 省略部分前置代碼
// 首先還是讀取JS入參
napi_value argv[2] = { 0 };
napi_get_cb_info(env, info, &argc, argv, &thisVar, &data);
auto asyncContext = new MyAsyncContext(); //建立結構體用于儲存各種需要在異步線程中傳遞的資料資訊
asyncContext->env = env;
// callback回調方式和promise回調在ts接口檔案中展現為2個獨立的接口方法,但它們的接口名稱相同,在C++側是由同一個方法來實作的。
// 這裡通過判斷JS傳入的第二個參數是不是function類型,來判定使用者調用的是callback回調接口,還是promise回調接口。
napi_valuetype valueType = napi_undefined;
napi_typeof(env, argv[1], &valueType);
// 為異步方法建立臨時傳回值。根據我們的ts接口檔案定義,callback接口傳回一個void就行, promise接口傳回一個promise對象
napi_value tmpRet = nullptr;
if (valueType == napi_function) { // Js調用的是callback接口
// 為js調用者傳入的js fuction建立一個napi引用并儲存到asyncContext中,以便後續在C++異步線程中能夠回調該js fuction
napi_create_reference(env, argv[1], 1, &asyncContext->callbackRef);
// callback接口傳回參數為void,構造一個undefined的傳回值即可。
napi_get_undefined(env, &tmpRet);
} else { // Js調用的是promise接口
// 建立promise對象。tmpRet用于傳回promise對象給js調用者, asyncContext->deferred用于後續在C++的異步線程中傳回正真的計算結果
napi_create_promise(env, &asyncContext->deferred, &tmpRet);
}
napi_value resource = nullptr;
// 建立異步工作 (内部實際是使用了libuv元件的異步處理能力,需要開發者自定義兩個callback方法)
napi_create_async_work(
env, nullptr, resource,
[](napi_env env, void* data) { // 1)execute_callback 方法,該方法會在libuv新開的獨立線程中被執行
MyAsyncContext* innerAsyncContext = (MyAsyncContext*)data;
// 需要異步處理的業務邏輯都放在這個execute_callback方法中,運算需要的資料可以通過data入參傳進來。
innerAsyncContext->status = 0;
// ......
},
[](napi_env env, napi_status status, void* data) { // 2)complete_callback方法,在js應用的主線程中運作
MyAsyncContext* innerAsyncContext = (MyAsyncContext*)data;
napi_value asyncResult;
// complete_callback回到了主線程,一般用于傳回異步計算結果。execute_callback和complete_callback之間可以通過data傳遞資料資訊
// 計算結果一般是從data中擷取的,這裡略過直接寫死
napi_create_string_utf8(env, "Visit Count = 65535", NAPI_AUTO_LENGTH, &asyncResult);
if (innerAsyncContext->deferred) {
// promise 方式的回調
// innerAsyncContext->deferred是前面步驟中建立的promise延遲執行對象(此時js調用者已經拿到了該promise對象)
napi_resolve_deferred(env, innerAsyncContext->deferred, asyncResult);
} else {
// callback 函數方式的回調
napi_value callback = nullptr;
// 通過napi_ref擷取之間js調用者傳入的js function,并調用它傳回計算結果
napi_get_reference_value(env, innerAsyncContext->callbackRef, &callback);
napi_call_function(env, nullptr, callback, 1, &asyncResult, nullptr);
napi_delete_reference(env, innerAsyncContext->callbackRef);
}
// 在異步調用的結尾釋放async_work和相關業務資料的記憶體
napi_delete_async_work(env, innerAsyncContext->work);
delete innerAsyncContext;
},
(void*)asyncContext, &asyncContext->work);
// 執行異步工作
napi_queue_async_work(env, asyncContext->work);
// 傳回臨時結果給js調用 (callback接口傳回的是undefined, promise接口傳回的是promise對象)
return tmpRet;
}
異步實作的主體代碼通過注釋應該能夠了解了。這裡再說兩點:
5.2.1 異步工作流程
libuv是一個基于事件驅動的異步io庫,NAPI用它來實作了異步工作處理流程。 napi_create_async_work()建立異步工作時要求傳入的execute_callback()和complete_callback(),也是沿用了libuv内部uv_queue_work的工作方式(見OpenHarmony\third_party\libuv\src\threadpool.c)。
其中第一個回調方法,也就是execute_callback,會在獨立的線程中運作。
5.2.2 napi_value與napi_ref
在callback回調方式的處理流程中,用到了這3個與napi_ref相關的方法:
- napi_create_reference() : 将napi_value包裝成napi_ref引用對象
- napi_get_reference_value() : 從napi_ref引用對象中取得napi_value
- napi_delete_reference() :删除napi_ref引用對象
當我們需要跨作用域傳遞napi_value時,往往需要用到上面這組方法把napi_value變成napi_ref。這是因為napi_value本質上隻是一個指針,指向某種類型的napi資料對象。NAPI架構希望通過這種方式為開發者屏蔽各種napi資料對象的類型細節,類似于void* ptr的作用 。既然是指針,使用時就需要考慮它指向的對象的生命周期。
在我們的例子中,我們通過GetVisitCountAsync()方法的入參得到了js應用傳遞給C++的 callback function,存放在napi_value argv[1]中。但我們不能在complete_callback()方法中直接通過這個argv[1]去回調callback function(通過data對象傳遞也不行)。這時因為當代碼執行到complete_callback()方法時,原先的主方法GetVisitCountAsync()早已執行結束, napi_value argv[1]指向的記憶體可能已經被釋放另作他用了。
NAPI架構給出的解決方案是讓開發者通過napi_value建立一個napi_ref,這個napi_ref是可以跨作用域傳遞的,然後在需要用到的地方再将napi_ref還原為napi_value,用完後再删除引用對象以便釋放相關記憶體資源。(有點像給智能指針增加引用計數的效果,内部實際是如何實作的待進一步探究源碼)
更多原創内容請關注:深開鴻技術團隊
入門到精通、技巧到案例,系統化分享HarmonyOS開發技術,歡迎投稿和訂閱,讓我們一起攜手前行共建鴻蒙生态。