跨端数据交互解析:从原理到性能优化

150 阅读11分钟

1、引言

在移动应用开发中,Hybrid架构已经成为平衡开发效率和原生体验的关键技术,架构的核心是在一个Native容器中,嵌入一个或多个Web容器,并利用Web技术栈来构建业务功能。这种模式的优势在于多端的代码复用,动态更新和快速迭代。

Hybrid架构带来的其中一个技术挑战在于,Native与JS之间的数据通信,这两个环境在底层是完全隔离的:

  • Native层:运行在操作系统环境中,由Objective-C/Swift运行时(IOS)或ART/JVM(Android)管理,可以直接访问系统级的API和硬件资源。
  • JS层:运行在独立的JS引擎(QuickJS,V8,JavaScriptCore等)的虚拟机中,是一个受限的沙箱环境。

这两个独立的运行环境之间无法共享数据或调用彼此的方法,因此需要通过一个明确定义的“通信通道”来完成,这通常会涉及到某种形式的进程间通信(IPC)以及序列化的机制。本文希望深入剖析这些通信通道的技术原理和演进路线,并探讨数据共享对于性能的影响,我们将回答如下的问题:

  • 通信通道是如何演进的? 从早期的URL Schema拦截,到官方标准提供的Script Message Handler,再到React Native采用的JavaScript Interface(JSI),它们在底层实现上有哪些差异?
  • 数据共享的策略如何优化? 对于需要在Native和JS两边进行共享的数据,从简单粗暴的全量注入,到提供按需的API调用,再到基于内存引用的读取,其优化路径具体是怎样的?

2、通信通道的演进:从消息传递到直接调用

在Native与JS的通信中,其核心是解决JavaScript引擎(VM)与Native运行时之间的通信问题,所有的通信机制,我们称之为“通道”,且设计和演进都围绕着性能、同步性和标准化的目标。

阶段一:URL Schema拦截

这是一个最早被广泛采用的通信机制,其本质是巧妙地利用了WebView加载URL的标准流程,将其Hack成一个通信通道。在智能设备发展的早期(2009-2012期间),官方并未提供标准的Native <-> JS双向通信的API,由社区的开发者们提出,并在最早期的跨平台框架中使用,成为当时跨端应用功能的实现标准。

该机制的实现原理是将一次函数调用信息编码成一个非标准的URL,然后利用WebView的导航拦截能力来捕获并解析这个URL,并执行后续的逻辑。

  1. 编码、封装与调用:JS侧将要调用的方法名,参数等信息,按照URL规范编码成一个字符串,例如myapp://user/getInfo?userId=12345

  2. 触发一个导航请求:JS通过创建一个隐藏的iframe,设置它的src属性来发起一次“导航请求”。

  3. 拦截、解析与执行

    1. 拦截:WebView在处理任何导航请求之前,会先调用其WebViewClientshouldOverrideUrlLoading方法,询问宿主App是否要接管这个请求。
    2. 解析与执行:如果匹配对应的schema,则证明这是一条来自JS侧的消息,而非真正的网页跳转,并转发给相应的Native模块来执行,最后返回true来告知WebView无需进行后续的加载,从而完成了一次单向通信。
  4. 执行JS回调:JS侧无法拿到Native返回结果,需要通过evaluateJavaScript来执行JS的回调。

image.png

代码示例

function callNative(command, params) {
    const scheme = 'myapp://';
    let url = `${scheme}${command}`;
    if (params) {
        const queryString = Object.keys(params).map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`).join('&');
        url += `?${queryString}`;
    }
    const iframe = document.createElement('iframe');
    iframe.style.display = 'none';
    iframe.src = url;
    document.body.appendChild(iframe);
    setTimeout(() => document.body.removeChild(iframe), 100);
}

// 实际调用
callNative('user/getInfo', { userId: '12345' });
// WebViewClient
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
    val url: Uri = request?.url ?: return false
    if ("myapp" == url.scheme) {
        val command = url.host.orEmpty() + url.path.orEmpty()
        val userId = url.getQueryParameter("userId")
        // ... 根据command和参数执行相应逻辑 ...
        return true // 返回true,表示应用已处理,WebView无需加载
    }
    return false
}

存在的问题

通信是单向的,JS侧无法拿到Native返回的结果。如果需要拿到返回值,就需要Native通过类似evaluateJavaScript来异步调用JS侧预设的回调函数,这种通信模型会导致实现一次完整的请求-响应调用,变得异常复杂并且容易出错。

阶段二:Script Message Handler—标准化的消息通道

为提供更规范的通信能力,平台方逐渐提供了官方的API,IOS基于WKScriptMessageHandler实现,Android基于addJavascriptInterface实现,这里只对Android的实现进行展开。

该机制的实现原理是在JS的全局上下文中,注入一个由Native定义的代理对象,JS对这个代理对象方法的调用,会在WebView底层转化为一个异步消息,最终在Native侧触发对应的方法。

  1. 注入代理对象:Native侧调用myWebView.addJavascriptInterface(new WebAppInterface(), "AndroidBridge"),在JS全局的上下文中注入一个AndroidBridge代理对象。
  2. 调用代理对象方法:JS侧调用window.AndroidBridge.processUserInfo(...),在JS引擎发现这是一个代理对象的调用,于是会被拦截,并把控制权交给代理对象的C++实现。
  3. 消息打包,放入异步队列:在代理对象的C++内部会将这次调用的所有关键信息(目标对象名称、方法名和参数等)打包成一个结构化的内部消息,然后放入一个Native与JS通信的消息队列。
  4. 消费队列:会有一个独立的后台线程(Bridge Thread)来持续监控并消费上述的队列,当发现有新消息时,就会取出,并解析消息内容,调用对应的方法。
  5. 解码消息,反射调用:在Bridge Thread会解析消息内容,并通过反射来查找WebAppInterface类中一个带有@JavascriptInterface注解,方法名为processUserInfo,且参数类型匹配的方法。
  6. 执行JS Callback & 返回结果:Native会执行对应的processUserInfo方法,在结束后通过调用evaluateJavascript来执行JS设置的回调,并将结果返回给JS Code侧。

image.png 代码示例

class WebAppInterface(private val context: Context) {
    @JavascriptInterface // 必须添加此注解,方法才能被JS调用
    fun processUserInfo(jsonString: String) {
        // 注意:此方法默认在后台线程执行
        val name = JSONObject(jsonString).getString("name")
        (context as? Activity)?.runOnUiThread {
            Toast.makeText(context, "Received user: $name", Toast.LENGTH_SHORT).show()
        }
    }
}
// 在WebView设置时注入实例
myWebView.addJavascriptInterface(WebAppInterface(this), "AndroidBridge")
if (window.AndroidBridge) {
    const userInfo = { name: "Jane Doe" };
    window.AndroidBridge.processUserInfo(JSON.stringify(userInfo));
}

存在的问题

异步性和通信延迟。尽快MessageHandler解决了返回通道的问题(基于回调的方式来实现),但无法解决其核心的异步消息模型带来的问题。每一次通信都涉及消息的封包、入队、线程切换、出队处理,带来了不可避免的延迟,无法满足高频、低延迟的场景。

阶段三:JavaScript Interface (JSI)—同步调用

为了满足上述提到的高性能、低延迟场景的诉求,React Native 团队在2018年开始启动对其架构的重构项目,新架构的核心就是JSI(JavaScript Interface),它彻底抛弃了异步消息队列模型,转而采用直接内存与函数绑定的方式,实现同步调用,为高性能、低延迟的交互提供了根本性的解决方案。

该机制的实现原理在于两个革命性的设计:

  • 统一的C++抽象层:JSI本身是一套与JS引擎无关的C++头文件接口,它定义了一套标准的API,例如jsi::Runtimejsi::Object等,无论是V8,QuickJS还是JavaScriptCore,React Native都会为这些引擎一个实现JSI接口的C++适配层,C++从而成为连接Native与JS的通用语言。
  • HostObject:一个HostObject是一个特殊的C++对象,它可以被注入到JS的运行时中。从JS的角度看,它是一个普通的对象,但实际上是一个指向Native内存中C++对象的代理。当在JS侧尝试访问这个对象属性时,JS引擎识别出来是一个HostObject后,会同步在JS线程的调用栈中,调用对应的getset方法,整个过程是一次无缝的,跨语言的函数调用,没有线程切换。

1、同步调用的示例

image.png

代码示例

#include <jsi/jsi.h>
class UserHostObject : public facebook::jsi::HostObject {
private:
    std::string name_ = "Native User";
public:
    // 当JS访问属性时,此方法被同步调用
    facebook::jsi::Value get(facebook::jsi::Runtime& runtime, const facebook::jsi::PropNameID& propName) override {
        auto prop = propName.utf8(runtime);
        if (prop == "name") {
            return facebook::jsi::String::createFromUtf8(runtime, this->name_);
        }
        return facebook::jsi::Value::undefined();
    }
};

// 在JNI初始化函数中,将HostObject实例安装到JS全局对象
runtime.global().setProperty(runtime, "nativeUser", jsi::Object::createFromHostObject(runtime, std::make_shared<UserHostObject>()));
// JSI初始化后,可以直接同步访问,无需任何回调
console.log("1. JS: 准备同步获取用户名...");
const userName = global.nativeUser.name; // 阻塞,直到C++返回
console.log("2. JS: 成功获取用户名:", userName); // -> "Native User"

2、异步调用的示例

image.png

代码示例

// 在UserHostObject的get方法中
if (prop == "fetchInfoAsync") {
    return jsi::Function::createFromHostFunction(runtime, propName, 0,
        [this](jsi::Runtime& runtime, const jsi::Value&, const jsi::Value* args, size_t) -> jsi::Value {
            // C++: 创建一个Promise
            return jsi::createPromiseAsJSIValue(runtime, 
                [this](jsi::Runtime& rt, std::shared_ptr<jsi::PromiseResolver> resolver) {
                    // C++: 在后台线程执行耗时操作
                    std::thread([this, &rt, resolver]() {
                        // 模拟网络请求
                        sleep(1); 
                        std::string result = "{"name":"Async User"}";
                        // C++: 在可以与JSI交互的线程中resolve Promise
                        // (在实际项目中,需要一个机制来安全地将任务调度回JS线程)
                        resolver->resolve(rt, jsi::String::createFromUtf8(rt, result));
                    }).detach();
                });
        });
}
async function fetchUser() {
    console.log("1. JS: 准备异步获取用户信息...");
    // 2. JS: 调用返回Promise的函数
    const userInfoJson = await global.nativeUser.fetchInfoAsync();
    const userInfo = JSON.parse(userInfoJson);
    // 7. JS: Promise完成,成功获取数据
    console.log("7. JS: 成功获取异步用户信息:", userInfo);
}
fetchUser();

存在的问题

JSI需要代理Object所有属性的get和set接口,在一些高频调用的场景,需要频繁地在Native侧进行查找,在性能上会有一些损耗。NAPI(Node-API)是来自Node.js社区的另一种jsbinding的方案,它并不代理Object所有的get和set接口,而是在编译期就确定注入哪些方法,相比JSI失去了灵活性,但是在高频调用场景下的性能会有较大的优势。

3、共享内存的性能优化之路

在拥有合适的通信通道之后,我们还需要关注如何使用这条通道来传输数据,在这一部分将会以一个实际的案例来展开如果通过一些策略演进,来优化Native与JS在数据交互过程中的性能。

先简单描述一下需求的背景,在短视频应用中,应用的Feed流为了有比较好的滑动性能,一般会基于Native来实现,而Feed的二级页面为了保持快速迭代和灵活性,一般会基于动态化技术栈来做。这里会有一个很常见的场景,当用户进入二级页面时,往往需要获取当前Feed的数据,甚至还会修改其中的数据,因此保证数据传输过程中的性能是一个关键的问题。

第一版:全量数据的传输

最初的版本是一个比较简单粗暴的实现,在Native侧持有一个Java对象,通常需要把数据转换成Map或者JSON格式才能与JS进行互相通信,对于Feed这种数据量巨大的对象,在Native侧通常不关心具体返回值的类型,因此会先通过转换成JSON,然后再转换成Map。最后将这个Map对象传递至JS的C++层,构建对应的jsi::object,返回给JS侧使用。

性能数据

在线下对低端机的测试中,单个Feed数据大小在44KB左右,传输耗时在1137ms

public Map<String, Object> getCurrentVideoData() {
    VideoModel videoModel = this.getCurrentVideo(); // 获取原始Java对象
    // 第一次转换 (Java Object -> JSON String)
    String jsonString = new Gson().toJson(videoModel);
    // 第二次转换 (JSON String -> Java Map)
    Type type = new TypeToken<Map<String, Object>>(){}.getType();
    Map<String, Object> dataMap = new Gson().fromJson(jsonString, type);
    // 将这个巨大的Map返回给C++层去处理
    return dataMap;
}
async function loadDetailPage() {
    // JS发起调用,然后长时间等待Native完成其复杂的转换流程
    const videoData = await jsb.getCurrentVideoData();
    // 直接使用数据渲染页面
    renderPage(videoData);
}

第二版:按需字段的传输

在第一版的方案中,对于首屏依赖Feed数据的页面,加载时间通常会很长,低端机会有很明显的卡顿或白屏。但通常页面首屏渲染依赖的数据只是Feed中的一小部分,因此可以提供一个按需获取字段的API,只需要传输一个裁剪后的Feed数据,会加快数据获取耗时。但由于也还是需要经过序列化和反序列化的过程,且部分场景依赖的字段也还是比较多,因此耗时优化得并不是很多。

性能数据

在线下对低端机的测试中,传输耗时在856ms

async function loadDetailPage() {
    // 1. 并行获取最关键的数据
    const [videoUrl, coverUrl] = await Promise.all([
        jsb.getVideoField('videoUrl'),
        jsb.getVideoField('coverUrl')
    ]);
    
    // 2. 立即开始渲染核心部分
    startVideoPlayback(videoUrl, coverUrl);
    
    // 3. 随后按需获取其他数据
    const authorName = await jsb.getVideoField('authorName');
    updateAuthorUI(authorName);
}

第三版:JSI Object引用的传输

为了进一步优化数据传输过程中的耗时,通过共享内存+动态代理的方式,在JS侧访问对象属性时,如果属性值是基础类型时,会触发JNI调用直接返回值,如果是复杂类型,则会递归生成新的代理对象并返回,从而可以避免全量序列化,极大提升数据传输性能。

具体来说可以分成三个阶段:

第一阶段是缓存Native对象,首先在Native侧创建一个JavaObject,然后在C++缓存一份,随后在Web页面初始化时在全局上下文注入一个objectManager,这是一个HostObject

第二阶段是在JS侧获取共享内存对象,执行objectManager.get("key"),在C++侧执行对应的Get方法,从C++缓存的Map中拿到对应的Java对象实例,在JSObjectManager中处理拿到的object,获取对应的Descriptor,一起返回给NativeObjectProxy进行初始化,缓存Descriptor,返回一个HostObject对象给JS侧。

第三阶段在JS侧获取共享内存对象里某个具体属性值,执行object.callFunc(),调用NativeObjectProxy的Get方法,如果是基础数据类型,则会从Descriptor中获取对应的JNI方法,直接执行Java方法,获取返回值后,处理成JS数据类型,返回给JS侧;如果是复杂数据类型,则会发送给JSObjectManager获取新的object和Descriptor,在NativeObjectProxy进行初始化和缓存,返回一个新的HostObject对象给JS侧。

性能数据

由于在JS侧获取基础数据类型时,才会发起一次JNI调用,其余情况只会返回一个新的HostObject对象引用,因此整体的数据传输耗时非常低。在线下对低端机的测试中,获取单个属性值的传输耗时在1ms

image.png

基础数据类型

image.png

复杂数据类型