天天看點

#夏日挑戰賽#OpenHarmony 源碼解析之NAPI架構内部實作分析

本文正在參加星光計劃3.0–夏日挑戰賽

作者:張志成

前言

NAPI(Native API)是OpenHarmony系統中的一套原生子產品擴充開發架構,它基于Node.js N-API規範開發,為開發者提供了JavaScript與C/C++子產品之間互相調用的互動能力。這套機制對于鴻蒙系統開發的價值有兩方面:

  1. 鴻蒙系統可以将架構層豐富的子產品功能通過js接口開放給上層應用使用。
  2. 應用開發者也可以選擇将一些對性能、底層系統調用有要求的核心功能用C/C++封裝實作,再通過js接口使用,提高應用本身的執行效率。

1. NAPI在系統中的位置

NAPI在OpenHarmony中屬于UI架構的一部分。

#夏日挑戰賽#OpenHarmony 源碼解析之NAPI架構内部實作分析
#夏日挑戰賽#OpenHarmony 源碼解析之NAPI架構内部實作分析

實作一個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目錄,分别負責子產品注冊、引用對象管理、作用域管理等專項功能。

#夏日挑戰賽#OpenHarmony 源碼解析之NAPI架構内部實作分析

我們知道,一個子產品被設計成什麼樣,往往是由它面臨的問題決定的。為了了解這些目錄的各個組成部分發揮的作用,我們先來看看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數值對象,建立的過程如下圖所示:

#夏日挑戰賽#OpenHarmony 源碼解析之NAPI架構内部實作分析

可以看到,最終建立這個數值對象的工作是由JS引擎去完成的,引擎從自己的GlobalStorage中建立了一個新的GlobalHandle來儲存這個數值。

4. NAPI子產品注冊功能的實作

開發一個NAPI子產品,首先需要按照NAPI架構的機制要求實作注冊相關動作,通過注冊告訴鴻蒙系統你開發的這個lib庫的名稱,提供了哪些native方法,以及它們對應的js接口名稱是什麼。

下圖為一個NAPI接口“add()"的實作,C++代碼中定義了lib庫對應的module名稱,并在注冊回調方法中定義了js方法和C++方法的名稱映射關系。

#夏日挑戰賽#OpenHarmony 源碼解析之NAPI架構内部實作分析

圖左側的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的定義

#夏日挑戰賽#OpenHarmony 源碼解析之NAPI架構内部實作分析

這個ModuleManager類隻提供了寥寥幾個對外接口,外部程式想擷取到它連結清單中的module對象,隻能通過LoadNativeModule()方法,應該是它沒錯了。

在鴻蒙架構代碼中四處尋覓LoadNativeModule()之後,我們在各個NativeEngine的構造函數中,都發現了它的蹤迹。用法大體相同。

這裡以ArkNativeEngine實作為例,繼續上代碼:

#夏日挑戰賽#OpenHarmony 源碼解析之NAPI架構内部實作分析

ArkNativeEngine在自己的構造函數中,定義了一個回調方法"requireNapi",當js應用程式調用requireNapi("xxxModule")時,上圖這段回調代碼将會被執行。(在鴻蒙架構的js代碼中搜requireNapi會發現很多這樣的調用)。

我們注意到回調方法裡做了2件事:

  1. loadNativeModule() --- 加載子產品(讀取動态庫)
  2. registerCallback() --- 執行開發者定義的注冊回調函數 (儲存js和c++方法名稱映射關系)

先看loadNativeModule() 内部實作,最終執行到NativeModuleManager::LoadModuleLibrary() 方法中,執行系統調用LoadLibrary(path)加載動态庫。

其中path的定義可以參見 NativeModuleManager::GetNativeModulePath() 方法

#夏日挑戰賽#OpenHarmony 源碼解析之NAPI架構内部實作分析

這就是鴻蒙的各種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開發技術,歡迎投稿和訂閱,讓我們一起攜手前行共建鴻蒙生态。