[ReactNative翻译]深入了解React Native JSI

1,109 阅读13分钟

本文由 简悦SimpRead 转码,原文地址 engineering.teknasyon.com

在去年11月,我谈到了React Native JSI架构。而在这篇文章中,我将ta......

image.png 图片来源。cornerstoneseducation.co.uk/news/what-t…

在去年11月,我谈到了React Native JSI架构。而在这篇文章中,我将谈论这个新的架构以及如何在我们的项目中使用它。但首先,我们需要提到旧的React Native架构。

旧的React Native架构如何工作

正如你们中的一些人所知,在React Native中,JS端和Native端在Bridge基础上进行通信。因为JS端已经有了一个很好的隔离环境,它没有任何机制来与Native端对话。例如,你无法访问设备名称,或者在不创建Native模块的情况下,你无法获得当前设备的本地IP地址。

image.png 你需要创建Native模块来获得当前设备的本地IP地址。

在本地模块中,你用Java和ObjC语言创建API,从你的JS代码中访问本地端。而且这种情况不仅仅是React Native特有的。在Java中,你需要使用JNI来调用C++和本地C代码。同样地,你需要使用C或ObjC语言创建一个桥接层来调用Swift中的C++ APIs。在React Native中,Bridge结构以同样的方式工作。

image.png React Native中的桥接结构能够通过JSON消息进行通信。

桥梁提供了一个隧道,通过这个隧道,它在JS和Native之间进行消息传输。例如,为了获得一个设备的IP地址,我们需要调用Native端的 "getIPAddress"方法。当原生端获得这个消息时,它就得到了设备的真实IP地址,之后,它把这个地址包在另一个消息中,并通过Bridge把它发送给JS端。JS端从消息中获得这个IP地址,并可以在屏幕上显示。

image.png 在一个React Native应用程序中,相机开启流程示例如图所示

正如你们中的一些人所知,桥接流程并不是调用Native端的理想解决方案。它对这些消息进行了批处理。因为批处理系统对这些消息进行批处理,所以在用户设备上会发生一些滞后。由于这个原因,消息不能被即时发送到另一方。此外,还需要进行一些序列化操作,将消息封装成JSON格式。例如,即使你想发送一个简单的数字变量到Native端,这个变量也必须被转换为JSON字符串。而这一操作与原生通信相比是非常缓慢的。出于这个原因,我们需要用新的架构来取代Bridge。

JSI的出现

image.png JSC(JavaScript Core)、Hermes和V8引擎都支持JSI。

诸如JSC、Hermes和V8等JavaScript运行时是用C和C++编写的,因为它们需要在高性能下工作。想利用这种情况的开发者们创建了可以与原生端对话的C++ APIs。而他们称之为JSI(JavaScript Interface)。JSI为JavaScript Runtime提供一个抽象层。你可以把这个层看作是面向对象语言中接口的概念。在这些语言中,你指定要在接口中定义的功能,实现它的类就有义务覆盖它。同样地,通过在JSI侧执行这个操作,你可以直接从C++侧发送一个数字值,并由JavaScript侧获得它,而无需进行类型转换。

JavaScript和JSI中的变量定义

数字类型定义

在JavaScript侧,我们知道如何定义一个数字变量,如下。

// Javascript
const number = 42

那么,我们如何在C++端定义一个变量并将其传递给JS端?首先,让我们仔细看看数字在C++中是如何定义的。

// JSI (C++)
jsi::Value number = jsi::Value(42);

正如你在这里看到的,使用jsi命名空间中的**Value()**构造函数创建了一个Value类的实例。之后,这个实例可以直接在JavaScript端作为一个数字使用。

字符串类型定义

同样的,字符串变量在JS和C++方面的定义如下。

// JavaScript
const name = "Marc"

// JSI (C++)
jsi::Value name = jsi::String::createFromUtf8(runtime, "Marc")

这里,字符串变量是使用jsi命名空间中StringcreateFromUtf8方法创建的。

函数定义

如你所知,函数可以在JavaScript端定义如下。

// JS
const add = (first, second) => {
  return first + second
}

为了在C++端创建一个函数并在JavaScript端使用它,可以使用createFromHostFunction方法,如下所示。

// JSI (C++)
auto add = jsi::Function::createFromHostFunction(
    runtime,
    jsi::PropNameID::forAscii(runtime, "add"), // add function name
    2, // first, second variables (2 variables) 
    [](
        jsi::Runtime& runtime, 
        const jsi::Value& thisValue,
        const jsi::Value* arguments, // function arguments
        size_t count
    ) -> jsi::Value {
        double result = arguments[0].asNumber() + arguments[1].asNumber();
        return jsi::Value(result);
    }
);

JSI中的数字总是一种双数类型。上面创建的add方法可以直接在JavaScript端使用

// JavaScript
const result = add(5, 8)

这个方法也可以在C++端使用。

// JSI (C++)
auto result = add.call(runtime, 5, 8);

当然,为了像上面那样从全局命名空间使用add函数,我们需要对它进行如下定义。

// Javascript
global.add = add;

// JSI (C++)
runtime.global().setProperty(runtime, "add", std::move(add));

如果我们将我们创建的方法与桥梁中的其他本地模块进行比较,我们可以注意到这个函数没有被定义为同步,因此是同步运行的。正如你在这里看到的,操作的结果是在宿主函数中创建的,直接被JS端使用。如果add函数是一个桥接函数,我们需要与await关键字一起使用,如下所示。

const resul = await global.add(5, 2)

正如你所注意到的,JSI函数是直接的、同步的,并且是JavaScript运行时间中最快的调用方法。

回到IP地址的例子,为了实现这个场景,我们首先需要在C++中创建一个返回IP地址的方法,然后我们使用global属性在JS端加载这个函数,最后简单地调用这个函数。现在,我们不需要使用await关键字,因为我们可以直接调用该函数,而且我们可以像其他JS方法一样调用。此外,由于没有序列化过程,我们从额外的处理负载中解脱出来。

现在,仔细看看这个实现吧。

// JSI (C++)
auto getIpAddress = jsi::Function::createFromHostFunction(
    runtime,
    jsi::PropNameID::forAscii(runtime, "getIpAddress"), 
    0, // Takes no parameter so it have 0 parameters
    [](
        jsi::Runtime& runtime, 
        // thisValue, arguments ve count variables not necessary
        const jsi::Value&, 
        const jsi::Value*,
        size_t
    ) -> jsi::Value {
        // iOS or android side method will be called
        auto ip = SomeIosApi.getIpAddress();
        return jsi::String::createFromUtf8(runtime, ip.toString());
    }
);
runtime.global().setProperty(runtime, "getIpAddress", std::move(getIpAddress));

之后,我们可以从JS端调用它,如下。

// JavaScript
const ip = global.getIpAddress();

Bridge和JSI的区别

综上所述,我们可以说JSI技术将取代Bridge。虽然JSI和Bridge会在项目中存在一段时间,但Bridge很快就会被完全删除,所有的本地模块都会使用JSI。JSI创造了一个比Bridge更高性能的结构,因为它既能提供更快的速度,又能直接访问JS运行时间。

另一方面,在Bridge中,JS和Native端的通信是异步进行的,消息是以批处理的,像2个数字相加这样的简单操作是需要使用await关键字的。

由于在JSI中一切都默认为同步工作,所以它们也可以在顶层范围内使用。当然,对于长期运行的操作可以创建异步方法,并且可以很容易地使用承诺。

作为一个缺点,由于JSI访问JS运行时,不可能使用像谷歌浏览器那样的远程调试器。与此相反,我们可以使用Flipper桌面应用程序来调试我们的应用程序。

因为JSI成为了本地实现的抽象层,我们不需要直接使用JSI,也不需要了解C++内部。我们只需像以前那样从JS端调用本地函数。另外,Turbo Modules的API与Native Modules的API几乎一样。因此,RN生态系统中现有的每个Native Module都可以很容易地迁移到Turbo Modules中,而不需要从头开始重写。

现在,仔细看看MMKV库,了解JSI的实现方式。

MMKV库作为JSI的一个例子

react-native-mmkv是一个库,它在JSI的帮助下执行一个简单的键值存储操作。通过同步调用,它执行读/写操作的速度比AsyncStorage快30倍。

image.png 从iPhone 8设备上的存储中读取操作时间(ms)1000倍。而MMKV约为10ms,AsyncStorage约为230ms。大约,MMKV比AsyncStorage快23倍。

由于这些特点,mmkv库也是JSI的一个很好的例子。让我们看一下JSI实现的Android项目结构。我们可以从MainApplicaton.java开始,看看它是如何实现的。

public class MainApplication extends Application implements ReactApplication { 
 
  private final ReactNativeHost mReactNativeHost =
      new ReactNativeHost(this) {
        @Override
        public boolean getUseDeveloperSupport() {
          return BuildConfig.DEBUG;
        }
 
        @Override
        protected List<ReactPackage> getPackages() {
          return new PackageList(this).getPackages();
        }        
        @Override
        protected String getJSMainModuleName() {
          return "index";
        }        
        
        @Override
        protected JSIModulePackage getJSIModulePackage() {
          return new MmkvModulePackage();
        }
      };  
  
  @Override
  public ReactNativeHost getReactNativeHost() {
    return mReactNativeHost;
  }
}

与Bridge不同,JSI模块没有自动链接机制。由于这个原因,我们需要手动进行链接操作。在MainApplication.java中,我们需要在ReactNativeHost实例中覆盖getJSIModulePackage方法。这个方法返回MmkvModulePackage(),实现了JSIModulePackage。

当我们看MmkvModulePackage.java文件时,我们看到这个类重写了getJSIModules函数。

public class MmkvModulePackage implements JSIModulePackage {
  @Override
  public List<JSIModuleSpec> getJSIModules(
    ReactApplicationContext ctx, 
    JavaScriptContextHolder jsContext) {
    
    MmkvModule.install(jsContext,ctx.getFilesDir().getAbsolutePath() + "/mmkv");    
    
    return Collections.emptyList();
  }
}

如你所见,getJSIModules函数以列表形式返回JSIModuleSpec实例。而在返回行中,该函数只是返回一个空列表。这是因为这个函数只在JS线程中被调用。由于这样的调用是在创建bundle之前进行的,所以mmkv模块可以很容易地被加载到全局命名空间。如果我们在不同的线程,如Native Module线程中做这个,我们的应用程序就会因为在运行时出错而崩溃。

现在,让我们看看install方法是如何工作的。

public static void install(
    JavaScriptContextHolder jsContext, 
    String storageDirectory) {
        
    nativeInstall(jsContext.get(), storageDirectory);
}

private static native void nativeInstall(long jsiPtr, String path);

正如你在这里看到的,安装方法需要一个名为JavaScriptContextHolder的实例参数。这个类是一个混合的Java类,也包括作为C++实例的JavaScript运行时间。因此,将数值从Java转移到C++使其成为可能。这里的nativeInstall函数只是一个JNI函数,允许C++中的本地函数被Java调用。另外,语言之间的数据传输也在这里发生。

nativeInstall 函数在 C++ file 中定义如下。

extern "C"
JNIEXPORT void JNICALL
Java_com_reactnativemmkv_MmkvModule_nativeInstall(
    JNIEnv *env, 
    jobject clazz, 
    jlong jsiPtr, 
    jstring path) {
    
    MMKV::initializeMMKV(jstringToStdString(env, path));    
    
    auto runtime = reinterpret_cast<jsi::Runtime*>(jsiPtr);
    if (runtime) {
        install(*runtime);
    }    
// if runtime was nullptr, MMKV will not be installed. This should only happen while Remote Debugging (Chrome), but will be weird either way.
}

正如你在这里看到的,这个文件的Java_com_reactnativemmkv_MmkvModule部分与本地模块命名空间相匹配。env表达式是JNI环境,clazz参数是MMKV模块。参数jsiPtr持有JavaScript Runtime实例。参数path代表文件的路径,mmkv模块存储变量。注意,最后两个参数与Java中调用的参数相同。

nativeInstall(jsContext.get(), storageDirectory);

然后,由于的reinterpret_cast方法,jsiPtr变量被转换为jsi::Runtime。如果转换操作成功,安装方法被调用。如果它失败了,说明正在使用一个不支持JSI的环境。这个环境可以是我们提到的3个环境以外的引擎,也可以是Chrome远程调试器。

现在我们来看看install方法。

这个方法中的createFromHostFunction实例与我们前面提到的添加实例中的createFromHostFunction部分非常相似。键/值对从参数变量中获取,并与MMKV命名空间中的函数一起设置。

让我们看看另一个例子,视觉相机库。

在vision_camera库中使用JSI

在这个库中,我们可以直接访问帧的宽度和高度属性,并将它们直接传输到本地处理器插件中。

const frameProcessor = useFrameProcessor((frame) => {
    'worklet';
    
    console.log(`A new ${frame.width} x ${frame.height} frame arrived!`);
    
    const values = examplePlugin(frame);
    console.log(`Return values ${JSON.stringify(values)}`);
},[]);

这个worklet表达式意味着相关将在后台运行。因此,该操作将在不阻塞主线程的情况下进行。框架参数是一个JSI主机对象。换句话说,它是一个在C++中生成的对象,可以被JavaScript直接访问。frame.height这里的访问会触发getProperty方法,这实际上是C++代码。

现在我们来看看Frame object的属性。

export interface Frame {
  isValid: boolean;
  width: number;
  height: number;
  bytesPerRow: number;
  planesCount: number;
  toString(): string;
  close(): void;
}

我们看到,这些属性只在TypeScript端引入,在JavaScript端没有任何代码。事实上,Frame对象的属性只存在于C++端。对于这一点,我们可以通过查看HostObject文件来获得更详细的信息。

#pragma once

#import <jsi/jsi.h>
#import <CoreMedia/CMSampleBuffer.h>
#import "Frame.h"

using namespace facebook;

class JSI_EXPORT FrameHostObject: public jsi::HostObject {
public:
  explicit FrameHostObject(Frame* frame): frame(frame) {}

public:
  // By overriding the get and getPropertyNames functions, 
  //    properties can be used as JS object property.
  // For example, when frame.height is requested, 
  //    the get method here is called by setting the name property "height" below.
  jsi::Value get(jsi::Runtime&, const jsi::PropNameID& name) override;
  std::vector<jsi::PropNameID> getPropertyNames(jsi::Runtime& rt) override;
  void close();

public:
  // We cannot access directly to frame object
  //    Because this is C++ object and JS doesn't know how to interact with this object.
  //    Instead we use the get methods above.
  Frame* frame;
  
private:
  void assertIsFrameStrong(jsi::Runtime& runtime, const std::string& accessedPropName);
};

现在让我们看看属性是如何调用的。

#import "FrameHostObject.h"
#import <Foundation/Foundation.h>
#import <jsi/jsi.h>

// This method returns all of keys
std::vector<jsi::PropNameID> FrameHostObject::getPropertyNames(jsi::Runtime& rt) {
  std::vector<jsi::PropNameID> result;
  result.push_back(jsi::PropNameID::forUtf8(rt, std::string("toString")));
  result.push_back(jsi::PropNameID::forUtf8(rt, std::string("isValid")));
  result.push_back(jsi::PropNameID::forUtf8(rt, std::string("width")));
  result.push_back(jsi::PropNameID::forUtf8(rt, std::string("height")));
  result.push_back(jsi::PropNameID::forUtf8(rt, std::string("bytesPerRow")));
  result.push_back(jsi::PropNameID::forUtf8(rt, std::string("planesCount")));
  result.push_back(jsi::PropNameID::forUtf8(rt, std::string("close")));
  return result;
}

// Returns a value based on propName'e
jsi::Value FrameHostObject::get(jsi::Runtime& runtime, const jsi::PropNameID& propName) {
  auto name = propName.utf8(runtime);
  // ...
  
  // Returns frame height
  if (name == "height") {
    this->assertIsFrameStrong(runtime, name);
    auto imageBuffer = CMSampleBufferGetImageBuffer(frame.buffer);
    auto height = CVPixelBufferGetHeight(imageBuffer);
    return jsi::Value((double) height);
  }

  // For returning a function, we need to create host function as follows:
  if (name == "close") {
    auto close = [this] (jsi::Runtime& runtime, const jsi::Value&, const jsi::Value*, size_t) -> jsi::Value {
      if (this->frame == nil) {
        throw jsi::JSError(runtime, "Trying to close an already closed frame! Did you call frame.close() twice?");
      }
      this->close();
      return jsi::Value::undefined();
    };
    return jsi::Function::createFromHostFunction(runtime, jsi::PropNameID::forUtf8(runtime, "close"), 0, close);
  }

  //...

  return jsi::Value::undefined();
}

现在我们可以进入到自定义主机对象的创建过程。

自定义主机对象的创建

通过创建一个自定义主机对象,可以返回不同类型的数据,如下所示。

#pragma once

#include <jsi/jsi.h>
#include <jni.h>
#include <fbjni/fbjni.h>

using namespace facebook;

class ExampleHostObject : public jsi::HostObject {
  public:
    explicit ExampleHostObject() {} 
  public:
    std::vector<jsi::PropNameID> getPropertyNames(jsi::Runtime& rt) override {
      std::vector<jsi::PropNameID> result;
      result.push_back(jsi::PropNameID::forUtf8(rt, std::string("someValue")));
      return result;
    }

    jsi::Value get(jsi::Runtime&, const jsi::PropNameID& propName) override {
      auto name = propName.utf8(runtime);
      if (name == "someValue") {
        // number
        return jsi::Value(13);  
      }
      if (name == "someBool") {
        // bool
        return jsi::Value(true);  
      }
      if (name == "someString") {
        // string
        return jsi::String::creaeFromUtf8(runtime, "Hello!");  
      }
      if (name == "someObject") {
        // object
        auto object = jsi::Object(runtime);
        object.setProperty(runtime, "someValue", jsi::Value(13));
        object.setProperty(runtime, "someBool", jsi::Value(true));
        return object;
      }
      if (name == "someArray") {
        // array
        auto array = jsi::Array(runtime, 2);
        array.setValueAtIndex(runtime, 0, jsi::Value(13));
        array.setValueAtIndex(runtime, 1, jsi::Value(true));
        return array;
      }
      if (name == "someHostObjec") {
        // object (C++)
        auto newHostObject = std::make_shared<ExampleHostObject>();
        return jsi::Object::createFromHostObject(runtime, newHostObject);
      }
      if (name == "someHostFunction") {
        // function
        auto func = [](
          jsi::Runtime& runtime,
          const jsi::Value& thisValue,
          const jsi::Value* arguments,
          size_t count
          ) -> jsi::Value 
          {
            double result = arguments[0].asNumber() + arguments[1].asNumber();
            return jsi::Value(result);
          };
          
          return jsi::Function::createFromHostFunction(
            runtime,
            jsi::PropNameID::forAscii(runtime, "func"),
            2, // first, second
            func);
      }

      return jsi::Value::undefined();
    }
};

如果该函数是一个全局函数,如Promise,那么我们可以按如下方式调用。

auto promiseCtor = runtime.global().getPropertyAsFunction(runtime, "Promise");
auto promise = promiseCtor.callAsConstructor(runtime, resolve, reject);

如果是匿名函数,如(const x = () => ...),我们需要把它作为参数传递给C++函数。

auto nativeFunc = jsi::Function::createFromHostFunction(runtime,
  jsi::PropNameID::forAsci(runtime, "someFunc"),
  1,  // a function
  [](jsi::Runtime& runtime,
  const jsi::Value& thisValue, 
  constjsi::Value* arguments, 
  size_t count) -> jsi::Value {
    auto func = arguments[0].asObject().asFunction();
    return func.call(runtime, jsi::Value(42));
});

总结

有了JSI,似乎很明显,模块和使用这些模块的应用程序的性能正在提高。由于JSI只是改变基础设施,我认为它不会影响日常的React Native应用开发。然而,如果你是一个库的维护者,我认为学习一些C++并将一个简单的库迁移到JSI是很有用的。

在我的下一篇文章中再见到你...

资源


www.deepl.com 翻译