React Native JSI’a Derinlemesine Bakış
Önceki yazımda React Native’in yeni mimarisinin ne gibi özelliklere sahip olacağına değinmiştim. Aradan yaklaşık 1.5 yıl geçtikten sonra yeni mimarinin yapısı giderek olgunlaştı ve reanimated gibi kütüphaneler de geçiş yapmaya başladı. React Native EU 2021 konferansında sektörün önde gelen isimleri React Native’deki yeni mimariden ve diğer yeniliklerden bahsetti. Bu isimlerden biri olan Marc Rousavy, How JSI powers the most advanced Camera library (VisionCamera Frame Processing) konuşmasıyla kendi ürettiği Vision Camera ve MKKV kütüphanelerinde JSI’ı nasıl kullandığından bahsetti. Ben de bu yazımda sizlere, bu konuşmadan edinmiş olduğum notları paylaşacağım.
Giriş
Halihazırda React Native, JavaScript ve Native tarafın birbiriyle haberleşmesi için Bridge yapısı kullanıyor. JS runtime’ı oldukça izole bir ortama sahip olduğu için native taraf ile konuşacak bir mekanizmaya sahip değildir. Tabii ki fonksiyonlar, değişkenler, nesneler ve birçok kavram oluşturarak kullanılabilir. Ancak native özelinde örneğin, device name veya IP adres gibi alanlara erişilmek istendiğinde JavaScript tarafında dahili olarak bir özellik bulunmamaktadır.
Native API’lere erişim işlerinde ObjC ve Java dillerinde kodlanmış platform specific API’ler kullanılmaktadır. Tabii bu dillere direkt olarak JS tarafından erişim sağlanamamaktadır. Bu durum, sadece React Native’e özgü de değildir. Örneğin Java'da, native C veya C++ kodunu çağırmak için JNI kullanmanız gerekir. Swift dilinde, C++ API'leri çağırmak için ise, C veya ObjC dili kullanarak bir iletişim katmanı (bridge) oluşturmak gerekir. React Native'deki bridge de benzer mantıkla çalışmaktadır.
Bridge bir tünel sağlar ve bu tünel sayesinde JS ve native arasındaki mesajların aktarımını gerçekleştirir. Örneğin bir IP adresi öğrenmek için native tarafa getIPAddress şeklinde bir çağrı yapmak gerekir. Native taraf bu mesajı aldığında gerçek IP adresi sorgular ve başka bir mesaj içerisine bu IP adresi koyarak bridge üzerinden JS tarafına geri iletir. JS tarafı, bu mesaj içerisindeki IP adresini edinir ve ekranda görüntüler.
Bildiğiniz gibi bridge akışı ideal bir çözüm değildir. Çünkü bridge, mesajları toplu bir şekilde işlemektedir (batching). Batching sistemi mesajları toplayarak ilettiği için küçük gecikmeler yaşanmaktadır. Bu nedenle mesajların karşı tarafa anında iletilmesi gibi bir durum söz konusu değildir. Ayrıca JSON formatında mesajlaşma gerçekleştiği için birçok serialization işlemi yapılmaktadır. Örneğin native tarafa basit bir number göndermek isteseniz bile bu ifade JSON bir string’e dönüştürülmektedir. Tabi tahmin edeceğiniz gibi oldukça yavaş bir süreç oluşmaktadır ve yeni bir mimariye ihtiyaç vardır
JSI’ın ortaya çıkışı
JSC, Hermes ve V8 gibi Javascript runtime’ları, yüksek performansla çalışması gerektiği için C ve C++ ile yazılmıştır. Bu durumdan yararlanmak isteyen geliştiriciler, native taraf ile konuşabilecek C++ api’leri oluşturdular ve buna JSI (JavaScript Interface) adını verdiler. JSI, bir JavaScript runtime’ına soyut bir katman oluşturmaktadır. Bu katmanı, nesne yönelimli dillerdeki interface kavramı gibi düşünebilirsiniz. Interface içerisine tanımlanacak fonksiyonları belirtirsiniz ve implement eden sınıflar bunu override etmekle yükümlü hale gelir. Benzer şekilde JSI tarafında da bu işlemin gerçekleştirilmesi ile, C++’taki bir sayıyı dönüşüm geçirmeksizin direkt olarak JavaScript tarafından number olarak edinebilirsiniz.
JavaScript ve JSI’da değişken tanımlamaları
number tipinde tanımlama
JavaScript tarafında bir sayı tanımlama işleminin aşağıdaki gibi olduğunu biliyoruz:
// Javascript
const number = 42
Peki C++ tarafında değişken tanımlayıp JS tarafına nasıl geçme işlemini nasıl yapacağız? Bunun için öncelikle C++’ta nasıl sayı tanımlandığına yakından bakalım:
// JSI (C++)
jsi::Value number = jsi::Value(42);
Burada gördüğünüz gibi jsi namespace’indeki Value() constructor’ı kullanılarak bir Value sınıfının bir instance’ı oluşturulmaktadır. Daha sonra bu instance, JavaScript tarafında direkt number olarak kullanılabilir.
String tipinde tanımlama
Benzer şekilde bir string tanımlama işlemi JS ve C++ tarafında da aşağıdaki şekildedir:
// JavaScript
const name = "Marc"// JSI (C++)
jsi::Value name = jsi::String::createFromUtf8(runtime, "Marc")
Burada da jsi namespace’indeki String içerisinde yer alan createFromUtf8 metodu kullanılarak string değişkenler oluşturulmaktadır.
Fonksiyon tanımlama
Bileceğiniz gibi JavaScript tarafında bir fonksiyon aşağıdaki gibi tanımlanmaktadır:
// JS
const add = (first, second) => {
return first + second
}
C++ tarafında bir fonksiyon oluşturup bunu JavaScript tarafında kullanmak için createFromHostFunction metodu kullanılır:
// JSI (C++)
auto add = jsi::Function::createFromHostFunction(
runtime,
jsi::PropNameID::forAscii(runtime, "add"), // add fonksiyonu
2, // first, second değişkenleri 2 adet
[](
jsi::Runtime& runtime,
const jsi::Value& thisValue,
const jsi::Value* arguments, // fonksiyon argümanları
size_t count
) -> jsi::Value {
double result = arguments[0].asNumber() + arguments[1].asNumber();
return jsi::Value(result);
}
);
JSI’daki sayılar her zaman double olarak yer almaktadır. Üstteki şekilde oluşturulan add metodu, JavaScript ortamında direkt olarak kullanılabilir:
// JavaScript
const result = add(5, 8)
Bu metod, C++ tarafında da aşağıdaki gibi kullanılabilir:
// JSI (C++)
auto result = add.call(runtime, 5, 8);
Tabii JS tarafında üstteki gibi global namespace’ten direkt olarak kulanmak için aşağıdaki gibi bir tanımlama yapmak gereklidir:
// Javascript
global.add = add;// JSI (C++)
runtime.global().setProperty(runtime, "add", std::move(add));
Oluşturduğumuz metodu bridge modüllerle kıyasladığımızda bu fonksiyonun async olarak tanımlanmadığını ve dolayısıyla senkron bir şekilde çalıştığını görürüz. Farkedeceğiniz gibi, işlemin sonucu host fonksiyonda oluşturulur ve JS tarafından direkt olarak kullanılır. Eğer add fonksiyonu bir bridge fonksiyonu olsaydı aşağıdaki gibi bir await keyword’ü ile kullanmamız gerekecekti:
const resul = await global.add(5, 2)
Farkettiğiniz gibi JSI fonksiyonları, JavaScript runtime’ında direkt, senkron ve en hızlı bir çağrım yöntemi olduğunu görmekteyiz.
IP adresi örneğine geri dönecek olursak, bu senaryoyu gerçekleştirmek için öncelikle C++ ile IP adres döndüren bir metot oluşturmamız, ve daha sonra bu fonksiyonu JS tarafına yüklemek için global özelliğini kullanmamız ve son olarak basit bir şekilde fonksiyonu çağırmamız gereklidir. Artık fonksiyona direkt olarak çağrım yapabildiğimiz için await keyword’ünü kullanmamıza gerek yoktur ve direkt olarak herhangi bir JS metodu gibi çağırabiliriz. Ayrıca JSON serialization işlemi olmadığı için ek bir işlem yükünden de kurtulmuş olmaktayız.
Şimdi implementasyona yakından bakalım:
// JSI (C++)
auto getIpAddress = jsi::Function::createFromHostFunction(
runtime,
jsi::PropNameID::forAscii(runtime, "getIpAddress"),
0, // Hiç parametre almıyor
[](
jsi::Runtime& runtime,
// thisValue, arguments ve count değişkenleri gerekli değil
const jsi::Value&,
const jsi::Value*,
size_t
) -> jsi::Value {
// ObjC veya Java tarafındaki metot çağrılır
auto ip = SomeIosApi.getIpAddress();
return jsi::String::createFromUtf8(runtime, ip.toString());
}
);runtime.global().setProperty(runtime, "getIpAddress", std::move(getIpAddress));
Sonrasında JS tarafında aşağıdaki gibi çağırabiliriz:
// JavaScript
const ip = global.getIpAddress();
Bridge ve JSI farklılıkları
Özet geçmek gerekirse JSI, Bridge’in yerini alacak bir teknoloji olduğunu söyleyebiliriz. Projelerde bir süre daha JSI ve bridge birlikte yer alacak olsa da yakında Bridge tamamen kaldırılacak ve tüm native modüller JSI kullanıyor hale gelecektir. JSI, brige’ten hem daha hızlı hem de direkt olarak JS runtime’ına erişim sağladığı için daha güçlü bir yapı oluşturmaktadır.
Bridge’te ise, JS ve Native taraftaki iletişim asenkron olarak gerçekleştiği ve mesajlar toplu olarak işlendiği için (batching system), 2 sayının toplanması kadar çok basit bir metot için bile await keyword’ü kullanılması gereklidir.
JSI’da her şey varsayılan olarak senkron olduğu için, top level scope’ta da kolaylıkla kullanılabilir. Tabii ki uzun süren işlemler için asenkron metotlar üretilebilir ve promise’ler kolaylıkla kullanılabilir.
Bir dezavantaj olarak JSI, Javascript runtime’ına eriştiği için Google Chrome gibi remote debugger kullanımı mümkün değildir. Bunun yerine Flipper kullanılması gerekecektir.
Ayrıca JSI, bridge’in yerini alacak bir teknolojidir ve sadece altyapıdaki teknoloji değişmektedir. Bu nedenle pek çok durumda direkt olarak JSI’ı kullanmanıza ve C++ kodu yazmanıza gerek olmayacaktır. Bununla birlikte, Turbo Modules API, neredeyse Native Modules API’ı ile aynıdır. Bu sayede, RN ekosisteminde yer alan halihazırdaki her bir Native Module, kodları tekrar yeni baştan yazmadan, kolaylıkla Turbo Modules’a migrate edilebilir.
Şimdi JSI’ın nasıl implement edildiğini anlamak için mmkv kütüphanesine bakalım.
Bir JSI örneği olarak mmkv kütüphanesi
react-native-mmkv kütüphanesi, JSI yardımıyla basit bir key value storage işlemini gerçekleştirmektedir. Senkron bir çağrım gerçekleştirerek AsyncStorage’dan 30 kat daha hızlı bir okuma/yazma operasyonu gerçekleştirmektedir.
Bu özelliklerinden dolayı mmkv, JSI için de iyi bir örnek teşkil etmektedir. Dilerseniz öncelikle Android proje yapısına bakalım. MainApplicaton.java’dan başlayarak nasıl entegre edildiğini görebiliriz:
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’in aksine JSI module için auto-linking mekanizması bulunmamaktadır. Bu nedenle linking işlemini elle yapmamız gereklidir. MainApplication.java içerisinde ReactNativeHost instance’ındaki getJSIModulePackage’ı override etmemiz gereklidir. Bu metot içerisinde JSIModulePackage interface’ini implement eden MmkvModulePackage() return edilmektedir.
MmkvModulePackage.java dosyasına baktığımızda da, bu sınıfın getJSIModules fonksiyonunu override ettiğini görürüz:
public class MmkvModulePackage implements JSIModulePackage {
@Override
public List<JSIModuleSpec> getJSIModules(
ReactApplicationContext ctx,
JavaScriptContextHolder jsContext) { MmkvModule.install(jsContext, ctx.getFilesDir().getAbsolutePath() + "/mmkv"); return Collections.emptyList();
}
}
Bu fonksiyon liste halinde JSIModuleSpec instance’larını döndürmektedir. Burada basitçe boş bir liste döndürüldüğü görülmektedir. Bunun nedeni, bu fonksiyonun aslında sadece JS thread’inde çağrılmaktadır. Bundle çağrılmadan önce bu şekilde bir çağrım yapıldığı için, kolaylıkla mmkv modülü global namespace’e yüklenebilir. Bunu eğer Native Module thread’i gibi farklı bir thread’de yapmış olsaydık, runtime’da bir hata alarak uygulamamız çökmüş olacaktı.
Şimdi install metodunun nasıl çalıştığına yakından bakalım:
public static void install(
JavaScriptContextHolder jsContext,
String storageDirectory) {
nativeInstall(jsContext.get(), storageDirectory);
}private static native void nativeInstall(long jsiPtr, String path);
Burada gördüğünüz gibi JavaScriptContextHolder adında bir instance almaktadır. Bu sınıf, hibrit bir Java sınıfı olup, JavaScript runtime’ını, C++ instance’ı olarak içermektedir. Böylece Java’dan C++’a değer aktarma işlemi sağlanmaktadır. Buradaki nativeInstall fonksiyonu bir JNI fonksiyonudur ve C++ içerisinde yer alan native metodun, Java tarafından çağrılmasını sağlar. Burada diller arası veri geçişi de gerçekleşmiş olur. nativeInstall fonksiyonu, C++ dosyasında aşağıdaki gibi yer almaktadır:
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.
}
Burada gördüğünüz gibi Java_com_reactnativemmkv_MmkvModule kısmı dosyanın java’daki namespace’ini oluşturuyor. env ifadesi JNI environment’ı olup, clazz parametresi ise MMKV modülünü ifade etmektedir. jsiPtr JavaScript runtime’ının pointer’ındır. path ise mmkv modülünün değişkenleri saklayacağı dosyanın yolunu ifade etmektedir. Dikkat edilirse son iki parametre ile Java’daki çağrım yapılan parametreler aynıdır:
nativeInstall(jsContext.get(), storageDirectory);
Devamında reinterpret_cast metodu sayesinde, jsiPtr değişkeni jsi::Runtime’a cast edilir. Eğer cast işlemi başarılıysa install metodu çağrılır. Eğer başarısız ise JSI desteklemeyen bir environment kullanılıyor demektir. Bu environment, saydığımız 3 environment haricindeki bir engine olabilir veya Chrome Remote Debugger olabilir.
Şimdi install metoduna bakalım:
Bu metot içerisindeki createFromHostFunction instance’ı, daha önce bahsettiğimiz add örneğindeki createFromHostFunction kısmına oldukça benzemektedir. key value değerleri arguments değişkeninden çekilerek MMKV namespace’indeki fonksiyon ile set edilmektedir.
Bir diğer örnek olan vision camera kütüphanesine bakalım.
Vision Camera kütüphanesinde JSI kullanımı
Bu kütüphanede frame’in width ve height özelliklerine direkt olarak erişebiliyor ve bir host function olan native processor plugin’e direkt olarak aktarabiliyoruz.
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)}`);},[]);
Buradaki worklet ifadesi ilgili fonksiyonun background’da çalışacağını ifade eder. Böylelikle main thread’i yormadan işlem gerçekleştirilebilir. frame parametresi ise bir JSI host nesnesidir. Başka bir deyişle, C++’ta üretilen bir nesnedir ve bu nesneye Javascript tarafından direkt olarak erişilebilmektedir. Buradaki frame.height erişimi ise aslında C++ kodu olan getProperty metodunu tetiklemektedir.
Şimdi Frame nesnesinin özelliklerine bakalım:
export interface Frame {
isValid: boolean;
width: number;
height: number;
bytesPerRow: number;
planesCount: number;
toString(): string;
close(): void;
}
Bu property’lerin yalnızca TypeScript tarafında tanıtılıp JavasScript tarafında herhangi bir kodunun bulunmadığını görmekteyiz. Aslında Frame nesnesinin property’leri, sadece C++ içerisinde yer almaktadır. Bunun için HostObject dosyasına bakarak daha detaylı bilgi edinebiliriz:
Şimdi ilgili property’lerin nasıl çağrıldığına bakalım:
Şimdi buradan custom host object oluşturma işlemine geçebiliriz.
Custom Host Object oluşturma
JSI kullanımı için custom host object oluşturularak farklı tiplerdeki veriler aşağıdaki gibi döndürülebilir:
JS runtime’daki global async bir fonksiyona erişimin sağlanması
Eğer promise gibi global bir fonksiyon ise aşağıdaki gibi bir çağrım yapılabilir.
auto promiseCtor = runtime.global().getPropertyAsFunction(runtime, "Promise");auto promise = promiseCtor.callAsConstructor(runtime, resolve, reject);
Eğer anonim bir fonksiyon ise (const x = () => …), C++ fonksiyonuna parametre olarak geçilmelidir:
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));
});
Sonuç olarak
JSI ile birlikte artık modüllerin ve bu modülleri kullanan uygulamaların performansının artacağı aşikar görünüyor. JSI sadece altyapıda olan bir değişiklik olduğu için gündelik RN yazımını da etkilemeyeceğini düşünüyorum. Ancak bir library maintainer iseniz biraz C++ öğrenmekte ve ilgili kütüphaneyi migrate etmekte yarar olduğunu düşünüyorum.
Sonraki yazımda görüşmek üzere…