在 Cef 中實現 C++ 與 JavaScript 交互場景分析


此文已由作者鄧佳佳授權網易雲社區發布。


歡迎訪問網易雲社區,了解更多網易技術產品運營經驗


本文主要介紹 CEF 場景中 C++ 和 JavaScript 交互(以下簡稱 JS Bridge)中的一些重要節點,包括了 C++/JavaScript 的方法注冊、方法調用、回調管理。以下是一些重要的參考資料:

現有實現的不足

在制作新的 JS Bridge 之前,團隊中已經有將 Cef 整合到項目中的離屏渲染實現,但 C++ 與 JavaScript 交互的代碼相對單一,僅實現了一些簡單的方法,沒有拓展性和統一性。也沒有處理一些多 Render 和多 Browser 實例的情況。比如我希望調用一個 C++ 的方法,需要重新在 Render 和 Browser 進程中實現單獨的通信代碼,這樣是非常麻煩的而且容易出錯。

期望的樣子

因為未來有跨平台的打算,所以側重點還是往偏前端一些,希望所有界面展示的功能均交由前端來實現。所以首先前端可以很方便的提供接口讓 C++ 調用,並且可以很方便的調用一個 C++ 接口並得到適當的回調返回信息。同理 C++ 端也希望能很容易的調用前端的方法或注冊方法提供前端調用。它們之間傳遞數據使用通用的 JSON 格式,在 C++ 端總是以字符串方式解析,而在前端總是以一個 Object 的方式解析。因為 JSON 的拓展性極高,當做橋梁之間傳遞數據的通道最合適不過了。

前端調用 C++ 方法的流程

Render 進程的 OnWebKitInitialized 接口在 WebKit 初始化完成后被調用,此時我們可以通過 CefRegisterExtension 來注冊一個拓展讓 WebKit 初始化完成后就執行這部分代碼,而這部分代碼就完全靠你發揮了,你可以聲明一個全局的對象,給該對象實現兩個方法來提供前端頁面注冊方法和調用 Native 的方法,如下所示:

void ClientApp::OnWebKitInitialized() 
{
    /**
     * JavaScript 擴展代碼,這里定義一個 NimCefWebFunction 對象提供 call 和 register 方法來讓 Web 端觸發 CefV8Handler 處理代碼
     * param[in] functionName 要調用的 C++ 方法名稱
     * param[in] params 調用該方法傳遞的參數,在前端指定的是一個 Object,但轉到 Native 的時候轉為了字符串
     * param[in] callback 執行該方法后的回調函數
     * 前端調用示例
     * NimCefWebHelper.call('showMessage', { message: 'Hello C++' }, (arguments) => {
     *    console.log(arguments)
     * })
     */
    std::string extensionCode = R"(
        var NimCefWebInstance = {};
        (() => {
            NimCefWebInstance.call = (functionName, arg1, arg2) => {
                if (typeof arg1 === 'function') {
                    native function call(functionName, arg1);
                    return call(functionName, arg1);
                } else {
                    const jsonString = JSON.stringify(arg1);
                    native function call(functionName, jsonString, arg2);
                    return call(functionName, jsonString, arg2);
                }
            };
            NimCefWebInstance.register = (functionName, callback) => {
                native function register(functionName, callback);
                return register(functionName, callback);
            };
        })();
    )";
    CefRefPtr<CefJSHandler> handler = new CefJSHandler();

     CefRegisterExtension("v8/extern", extensionCode, handler);
}

代碼中新增了一個 NimCefWebInstance 全局對象,並拓展了一個 call 方法和一個 register 方法,分別提供前端調用 C++ 方法和注冊本地的方法讓 C++ 調用。並且做了適當判斷,允許傳遞參數和不傳遞參數。如果你更了解 JavaScript 可以進一步拓展。

另外可以看到我們新建了一個派生於 CefV8Handler 類的 CefJSHandler 類,該類僅實現了一個方法,就是用來接收我們剛才注冊到頁面中的方法事件的。實現如下:

bool CefJSHandler::Execute(const CefString& name, CefRefPtr<CefV8Value> object, const CefV8ValueList& arguments, CefRefPtr<CefV8Value>& retval, CefString& exception)
{
    // 當Web中調用了"NimCefWebFunction"函數后,會觸發到這里,然后把參數保存,轉發到Broswer進程
    // Broswer進程的BrowserHandler類在OnProcessMessageReceived接口中處理kJsCallbackMessage消息,就可以收到這個消息

    if (arguments.size() < 2)
    {
        exception = "Invalid arguments.";
        return false;
    }

    CefRefPtr<CefV8Context> context = CefV8Context::GetCurrentContext();
    CefRefPtr<CefFrame> frame = context->GetFrame();
    CefRefPtr<CefBrowser> browser = context->GetBrowser();

    int64_t browser_id = browser->GetIdentifier();
    int64_t frame_id = frame->GetIdentifier();

    if (name == "call")
    {
        // 允許沒有參數列表的調用,第二個參數為回調
        // 如果傳遞了參數列表,那么回調是第三個參數
        CefString function_name = arguments[0]->GetStringValue();
        CefString params = "{}";
        CefRefPtr<CefV8Value> callback;
        if (arguments[0]->IsString() && arguments[1]->IsFunction())
        {
            callback = arguments[1];
        }
        else if (arguments[0]->IsString() && arguments[1]->IsString() && arguments[2]->IsFunction())
        {
            params = arguments[1]->GetStringValue();
            callback = arguments[2];
        }
        else
        {
            exception = "Invalid arguments.";
            return false;
        }

        // 執行 C++ 方法
        if (!js_bridge_->CallCppFunction(function_name, params, callback))
        {
            exception = nbase::StringPrintf("Failed to call function %s.", function_name).c_str();
            return false;
        }

        return true;
    }
    else if (name == "register")
    {
        if (arguments[0]->IsString() && arguments[1]->IsFunction())
        {
            std::string function_name = arguments[0]->GetStringValue();
            CefRefPtr<CefV8Value> callback = arguments[1];
            if (!js_bridge_->RegisterJSFunc(function_name, callback))
            {
                exception = "Failed to register function.";
                return false;
            }
            return true;
        }
        else
        {
            exception = "Invalid arguments.";
            return false;
        }
    }

    return false;
}

這里我們區分了 call 和 register 方法,並且進一步判斷了參數的傳遞順序。當前端執行了 call 方法時就可以將執行的函數名、傳遞參數保存下來,然后通知 Browser 進程去執行這個方法(前提是 Browser 端已經注冊過使用相同字符串命名的這個方法)。我將該操作傳遞給了一個 js_bridge 對象的 CallCppFunction 方法。這是我封裝的一個用來管理兩端注冊的方法和回調的管理類,並將兩端通訊的方法封裝了起來,如下所示:

bool CefJSBridge::CallCppFunction(const CefString& function_name, const CefString& params, CefRefPtr<CefV8Value> callback)
{
    auto it = render_callback_.find(js_callback_id_);
    if (it == render_callback_.cend())
    {
        CefRefPtr<CefV8Context> context = CefV8Context::GetCurrentContext();
        CefRefPtr<CefProcessMessage> message = CefProcessMessage::Create(kCallCppFunctionMessage);

        message->GetArgumentList()->SetString(0, function_name);
        message->GetArgumentList()->SetString(1, params);
        message->GetArgumentList()->SetInt(2, js_callback_id_);

        render_callback_.emplace(js_callback_id_++, std::make_pair(context, callback));

        // 發送消息到 browser 進程
        CefRefPtr<CefBrowser> browser = context->GetBrowser();
        browser->SendProcessMessage(PID_BROWSER, message);

        return true;
    }

    return false;
}

這里我們維護了一份 callback 的索引,每當發起新的調用時,這個索引值自增,並插入到我們管理回調的 map 結構中。map 中以 callback 索引為標准,存儲了運行環境和真正的 callback 實體。最后使用 SendProcessMessage方法通知 Browser 來執行我們要運行的代碼。當消息發出后,Browser 進程就會收到這個消息了。

bool BrowserHandler::OnProcessMessageReceived(CefRefPtr<CefBrowser> browser, CefProcessId source_process, CefRefPtr<CefProcessMessage> message)
{
    // 處理render進程發來的消息
    std::string message_name = message->GetName();
    if (message_name == kFocusedNodeChangedMessage)
    {
        is_focus_oneditable_field_ = message->GetArgumentList()->GetBool(0);
        return true;
    }
    else if (message_name == kCallCppFunctionMessage)
    {
        CefString fun_name    = message->GetArgumentList()->GetString(0);
        CefString param        = message->GetArgumentList()->GetString(1);
        int js_callback_id    = message->GetArgumentList()->GetInt(2);

        if (handle_delegate_)
            handle_delegate_->OnExecuteCppFunc(fun_name, param, js_callback_id, browser);

        return true;
    }
    else if (message_name == kExecuteCppCallbackMessage)
    {
        CefString param = message->GetArgumentList()->GetString(0);
        int callback_id = message->GetArgumentList()->GetInt(1);

        if (handle_delegate_)
            handle_delegate_->OnExecuteCppCallbackFunc(callback_id, param);
    }

    return false;
}

Browser 進程接收到消息后,判斷如果是 kCallCppFunctionMessage 消息類型那么就將要執行的函數名和參數傳遞給一個委托類去做具體的執行。實際委托類的子類中實現了這些執行 C++ 方法的虛函數,在實現的虛函數中解析了參數和要調用的函數名,通過 js_bridge 對象來執行曾經注冊過的方法。當 C++ 方法執行完以后,我們還要通知 Render 進程去執行回調函數,如下所示:

bool CefJSBridge::ExecuteCppFunc(const CefString& function_name, const CefString& params, int js_callback_id, CefRefPtr<CefBrowser> browser)
{
    CefRefPtr<CefProcessMessage> message = CefProcessMessage::Create(kExecuteJsCallbackMessage);
    CefRefPtr<CefListValue> args = message->GetArgumentList();

    auto it = browser_registered_function_.find(std::make_pair(function_name, browser->GetIdentifier()));
    if (it != browser_registered_function_.cend())
    {
        auto function = it->second;
        Post2UI([=]() {
            function(params, [=](bool has_error, const std::string& json_result) {
                // 測試代碼,需要封裝到管理器中
                args->SetInt(0, js_callback_id);
                args->SetBool(1, has_error);
                args->SetString(2, json_result);
                browser->SendProcessMessage(PID_RENDERER, message);
            });
        });
        return true;
    }
    else
    {
        args->SetInt(0, js_callback_id);
        args->SetBool(1, true);
        args->SetString(2, R"({"message":"Function does not exist."})");
        browser->SendProcessMessage(PID_RENDERER, message);
        return false;
    }
}

通過 SendProcessMessage 通知 Render 進程,我們要執行某個 Id 的 callback。當 Render 進程接收到這個消息后,會根據傳遞進來的 callback id 去 map 中尋找這個 callback 的運行環境和實體來執行 callback 並傳入 Browser 進程攜帶過來的參數。

C++ 調用前端方法流程

還記得上面提到的全局方法中有個 register 方法嗎?這個方法提供了前端注冊持久化的方法提供 C++ 調用。注冊的方法如下所示:

(() => {
    /*
     * 注冊一個回調函數,用於在 C++ 應用中調用
     * param[in] showJsMessage 回調函數的名稱,C++ 會使用該名稱來調用此回調函數
     * param[in] callback 回調函數執行體
     */
    NimCefWebInstance.register('showJsMessage', (arguments) => {
        const receiveMessageInput = document.getElementById('receive_message_input')
        receiveMessageInput.value = arguments.message
        return {
            message: 'showJsMessage function was executed, this message return by JavaScript.'
        }
    })
})()

同樣,在執行 register 方法注冊一個持久化方法時會進入到上面提到的我們自己注冊的 Handler::Execute 方法中。在一系列判斷后開始將注冊的函數放到 JS Bridge 維護的列表中,代碼如下:

bool CefJSBridge::RegisterJSFunc(const CefString& function_name, CefRefPtr<CefV8Value> function, bool replace/* = false*/)
{
    CefRefPtr<CefV8Context> context = CefV8Context::GetCurrentContext();
    CefRefPtr<CefFrame> frame = context->GetFrame();

    if (replace)
    {
        render_registered_function_.emplace(std::make_pair(function_name, frame->GetIdentifier()), function);
        return true;
    }
    else
    {
        auto it = render_registered_function_.find(std::make_pair(function_name, frame->GetIdentifier()));
        if (it == render_registered_function_.cend())
        {
            render_registered_function_.emplace(std::make_pair(function_name, frame->GetIdentifier()), function);
            return true;
        }

        return false;
    }

    return false;
}

存放這些持久化函數時,我們根據函數名和當前注冊函數所在的 frame id 為標准,為什么要加一個 frame id 呢?主要我們要考慮的是如果一個頁面下存在多個 frame,不同的 frame 我們要允許他們注冊同名的方法,在調用的時候去調用對應 frame 中的方法。另外一種情況就是如果你的 JS Bridge 是一個單例,它維護了所有 render 進程的所有 browser 實例的函數和回調列表,我們一樣還是要用一個唯一的數據來區分某個 callback 要在哪個 frame 里執行。frame 是最小單位,並且在我實戰情況下不同的 browser 下的 frame id 是不會重復的。所以用 frame id 做一個唯一標識是最靠譜的。 當 C++ 要調用前端已經注冊好的方法時,只需要到這個列表中根據名字和 frame id 找到對應的 frame,通過 frame 得到運行上下文(context),然后進入這個上下文執行環境執行具體的函數體就可以啦。代碼如下:

bool CefJSBridge::ExecuteJSFunc(const CefString& function_name, const CefString& json_params, CefRefPtr<CefFrame> frame, int cpp_callback_id)
{
    auto it = render_registered_function_.find(std::make_pair(function_name, frame->GetIdentifier()));
    if (it != render_registered_function_.cend())
    {

        auto context = frame->GetV8Context();
        auto function = it->second;

        if (context.get() && function.get())
        {
            context->Enter();

            CefV8ValueList arguments;

            // 將 C++ 傳遞過來的 JSON 轉換成 Object
            CefV8ValueList json_parse_args;
            json_parse_args.push_back(CefV8Value::CreateString(json_params));
            CefRefPtr<CefV8Value> json_object = context->GetGlobal()->GetValue("JSON");
            CefRefPtr<CefV8Value> json_parse = json_object->GetValue("parse");
            CefRefPtr<CefV8Value> json_stringify = json_object->GetValue("stringify");
            CefRefPtr<CefV8Value> json_object_args = json_parse->ExecuteFunction(NULL, json_parse_args);
            arguments.push_back(json_object_args);

            // 執行回調函數
            CefRefPtr<CefV8Value> retval = function->ExecuteFunction(NULL, arguments);
            if (retval.get() && retval->IsObject())
            {
                // 回復調用 JS 后的返回值
                CefV8ValueList json_stringify_args;
                json_stringify_args.push_back(retval);
                CefRefPtr<CefV8Value> json_string = json_stringify->ExecuteFunction(NULL, json_stringify_args);
                CefString str = json_string->GetStringValue();

                CefRefPtr<CefProcessMessage> message = CefProcessMessage::Create(kExecuteCppCallbackMessage);
                CefRefPtr<CefListValue> args = message->GetArgumentList();
                args->SetString(0, json_string->GetStringValue());
                args->SetInt(1, cpp_callback_id);
                context->GetBrowser()->SendProcessMessage(PID_RENDERER, message);
            }

            context->Exit();

            return true;
        }

        return false;
    }

    return false;
}

這樣前端應用就可以正常執行已經注冊過的函數了。另外在上面的代碼中,我們看到 ExecuteFunction 方法是又返回值的,這個返回值是前端 return 的數據。我們可以使用這個返回值再來通知 C++ 端執行的結果,我這里直接將執行結果通過進程間通信發送給了 C++ 端,雖然與前端調用 C++ 的回調實現不太一樣,但是還是可以達到我們的需求的。

總結

上面分別介紹了兩端互相注冊和調用對端方法的示例,實際情況還是自己要根據項目需求設計一下,上面的實現思路還是有一些缺陷的。比如調用函數使用的是字符串名字,這樣是挺不靠譜的做法,但從目前情況來看是最方便快捷的實現方式。但最終還是期望后期可以拓展成以對象方式直接調用對端方法,這可能要再對 Cef 做挖掘,根據自己實際項目情況再繼續拓展了。


免費領取驗證碼、內容安全、短信發送、直播點播體驗包及雲服務器等套餐

更多網易技術、產品、運營經驗分享請點擊


相關文章:
【推薦】 網易雲易盾發布多國家多語種內容安全服務,助力中國互聯網出海
【推薦】 從互聯網+角度看雲計算的現狀與未來(2)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM