This is the third day of my participation in the More text challenge.

Many applications today, in order to reach as many users as possible, choose to be developed on multiple major platforms simultaneously:

  • Mobile terminal: iOS, Android
  • Desktop: OSX, Windows, and Linux
  • Web side

All platforms are built on their own native technologies, and while the user experience is good, the development efficiency is very low. In order to balance user experience and development efficiency, there are a number of cross-platform solutions on the market:

  • Mobile: Apache Cordova, React Native, Weex, Flutter, etc
  • Desktop: QT, Apache Cordova, Electron, Flutter -desktop, etc
  • Web side: flutter – Web

Each of these solutions has its advantages and disadvantages. From the perspective of architecture, there are roughly the following patterns:

Several types of cross-platform solutions

1. Bridge

The core problem to be solved by bridging is communication between two languages (JS and native), or communication between JS threads and native threads. The capabilities of the native layer are encapsulated by the Bridge layer and then provided to the JS layer for invocation. In turn, the functions of the JS layer can also be invoked by the Bridge layer.

This model is much like the communication between a client and a server, where the client and server agree on a service interface (REST API) and exchange data via JSON. React Native borrows from this pattern, passing JSON back and forth over the JS Bridge.

The Bridges are represented by Cordova/React Native. The difference is that the UI layer of Cordova is rendered based on WebView, so you only need to bridge the Native base service. RN’s UI is platform-based, so it does a lot of bridging at the UI layer. Since the JS bridge layer relies on JSON to communicate, JSON performance bottlenecks can cause UI bottlenecks when large amounts of data are transferred between the two ends (complex animations, fast sliding of large lists).

2. Interprocess Communication (IPC)

On desktop systems, applications have more flexibility and can organize their applications by using multiple processes. We can also solve the call problem between JS and native languages through interprocess communication, which is represented by the solution: Electron.

Electron’s use of IPC is somewhat forced, as it relies on the Chromium Rengier engine to start a process for each window. This is a reasonable design for Chrome, a crash within a TAB will not cause the entire Chrome to crash. This is not necessary for desktop applications that rely on Electron, however, adding to the cost of IPC

Interprocess communication can use a number of ways to pass messages, such as the familiar pipe. Eletron, however, uses the same structured Clone algorithm as the Web Worker API postMessage to serialize and deserialize IPC data. The efficiency of this method is not much different from that of JSON, and there will also be performance problems when transferring a large amount of data. Therefore, Electron recommends CSS animation, and JS anination is not recommended.

3. Draw based on Canvas

Canvas drawing is the mainstream way to achieve cross-platform UI:

  • Platform rendering: Use JS to invoke the Native UI, which is how React Native works. The advantage is that performance is good enough most of the time; The downside is that the JS Bridge needs to be adapted to all supported platforms, and when platform-side UI controls want to be used in RN, it takes extra effort for developers to adapt.
  • Unified Rendering: Use other techniques to simulate the native UI. This is the approach taken by the Cordova/Electron. The advantage is that the code is simple, and the UI is rendered directly in a third-party webview. The disadvantage is that UI performance is affected by the single thread of JS and the rendering performance of webView itself, which tends to perform poorly in complex interactions.

While most of the technology stacks that choose a unified rendering scheme just focus on webView, people ignore the fact that all UI renderings end up being filled pixel by pixel on canvas. If we build a system that skips the complicated rendering logic of DOM/CSS/JS and directly customizes various controls to draw them on canvas, can we have both?

The only way to do this is with flutter. It uses Skia, chrome’s underlying graphics rendering engine, to design a set of efficient control libraries from the bottom up, with higher performance than WebView while not relying on platform-side controls.

Problems with existing cross-platform solutions

All of these solutions are still focused on cross-platform at the UI level, but what about the business logic code? It is difficult to ensure runtime efficiency to write with UI-layer languages like JS, so we finally have to use native language implementation. Originally, one language was the original intention, but we found that we had to learn three languages, iOS, Android, JS/ Dart. (Flutter may be a little stronger, But you still need to invoke the platform-side service via channel.

“Cross-platform at the business logic layer is a much more neglected issue than cross-platform UI.”

So why has logical tier cross-platform technology been so slow to progress? One of the main reasons is that without a suitable language tool, it is difficult to find the advantages of a single language that can cover so many platforms at once.

Before Rust matured, C/C++ was pretty much the only choice for cross-side business logic. Implement it once in C/C++, and then compile it into APP in a statically linked way on each end. Of course this involves a very thin layer of interface: bridging each platform native language to C/C++.

But C/C++ code (as opposed to Java/Kotlin/Swift) is more difficult to write, and cross-platform compilation links have a lot of holes to jump through, which can ultimately mask the benefits of “write once, link everywhere”.

Now with Rust, which has dependency management and ecology as good as any modern language, a very complete cross-platform compilation system and cross-language FFI support, and its own runtime-independent memory security and concurrency security, as well as almost top-quality WebAssembly support, Making it the perfect C/C++ cross-platform alternative. In addition to Rust’s own cross-platform toolchain, Rust Ecology also includes cargo Lipo (Encapsulating C FFI), a tool designed to simplify interoperability with the iOS native language, and JNI for Java interoperability, There’s even Android-NK-RS for Android.

Next, we need a set of ideas to organize native languages and Rust interoperability across platforms to solve the problem of universality.

The back end of the front end

The so-called back end of the front end, is on the basis of the separation of the front end, further separate the front-end partial UI business logic and partial data processing business logic. The part that handles the data processing, we call it the back end of the front end.

Basic architecture

Whether MVC or MVVC pattern is widely used in the front-end architecture, its first M, Model (including data, state, and business logic), is the “back end” that we need to separate and deal with uniformly. Using the bridge pattern mentioned earlier, we can imagine a model for separating the front and back ends of the front-end code:

The big difference between this model and a traditional cross-platform UI approach is that it lets all interested parties do what they do best, rather than forcing adaptation. Platform-specific code, such as UI, platform device access, etc., is implemented in the platform native language (or FLUTTER), which is better at doing this, while platform-independent business logic code, algorithms, and network layer code are implemented in Rust. In this way, Rust Backend does not have to spend a lot of effort wrapping things around the platform, but only does what Backend needs to do well.

Communication mode

Previous UI solutions used JSON or JSON-like serialization schemes. JSON is a very inefficient serialization scheme with low type safety. In such scenarios, we have more efficient, more efficient, more type safety schemes, such as Protobuf, Flatbuffers, etc.

deserialization serialization

Take the communication between Rust and Kotlin as an example. The communication flow using JSON and Protobuf is as follows:

JSON Protobuf

Rust and Kotlin compile defined Protos into platform code, respectively, and can then freely pass Protobuf data between the two ends.

The sample

For example, to display the front page of the movie site Tubi, suppose we implement this logic based on Clean Architecture’s MVP structure

  • The back end provides an API to get a list of moviesGET /api/v1/get_movies
  • To establish aTubiRepositoryProcess network layer requests
  • The response to the request is deserialized into Category/Movie Models and then given to Use Cases as Entity
  • Finally, the Presentation layer renders the results to the screen

Here, we try to implement Rust on the Model layer as follows:

  1. The method of exposure to native layer is:getMovies()
  2. GetMovies () internally serializes the parameters into a protobuf and passes it to a Rust functiondispatcher
  3. The Dispatcher deserializes the request and learns that it is a RequestgetMovie, which it dispatches toget_movies()
  4. Get_movies () reads data from the local cache, or if not, gets it from the remote API through Reqwest and caches it

From the native developer’s point of view, she calls getMovies() and returns the serialized Movie, Category, and other data structures, leaving the rest of the details alone.

As another example, when the user is watching a video, the client will periodically report the current viewing location to the server

  1. API: PUT /api/v1/update_history
  2. One is exposed in the native layerupdateHistory()The method of
  3. The dispatcher dispatches it to the Rust functionupdate_history()

From the above examples, we can roughly see what we can do on the Rust side:

  1. A more efficient network layer: Automatic connection pooling, better flow control, more flexible security processing, and unaware network layer processing on the UI side, such as upgrading REST API to gRPC, using Schnorr signature on THE API layer, or upgrading HTTP/2 to HTTP/3. You don’t have to care about the native side.
  2. Better data management. Rust has rich and efficient data structures that can be tailored to each type of data setup. We can also do very efficient data caching.
  3. On top of that, the data is enabled. For example, a simple index of data from get_movies() makes it easy to display and filter data in different dimensions.

How to maintain it continuously?

As mentioned above, we separate the front and back ends, and since both sides communicate based on a Protobuf, it is important to maintain the definition of a Protobuf message. Request and Response are the two most core messages:

  • Request: One of. It contains all the request interfaces for calling Rust functions from the native side, such as RequestGetMovies, RequestUpdateHistory, etc.

  • Response: One of type. It contains all response interfaces returned from Rust to native callers, such as ResponseGetMovies, ResponseUpdateHistory, etc.

Each time a new interface is added, we simply expand the definition of the two messages and add new types. Then do a protobuf codegen for all languages involved, generate new interface code, and populate the corresponding interface code on both sides. This step can be automated and is best integrated into Rust build.rs or Makefile. Finally, developers only need to write the logical code associated with Rust.

If the back-end interface is described based on the Open API spec, then we can even generate the corresponding Rust client invocation method and the gRPC for communication between Rust and Native based on the information in the Open API spec. It is theoretically possible to generate cross-end code for the entire network layer based on the Open API Spec without writing a single line of code, ultimately exposing the Native side to a simple and efficient getMovies().

Kotlin Native?

Kotlin Native is also a good choice, especially for Android developers. Rust’s main advantage over KN is probably performance

Benedikt also addressed this issue in his talk, “Sharing Code Between iOS & Android with Rust.” As a mobile developer who just clicked on the Rust skill tree, he made some simple benchmarks. First, he tries to sum the numbers less than 100 over a large string of various numbers.

Rust Kotlin Swift
The code is very similar, but the performance is tens of times worse:

Therefore, Rust rather than KN is more suitable for the development of some underlying libraries that attach importance to performance.

Finally, compare the three cross-platform technologies

technology performance Compilation process complexity Call existing platform libraries Called by an existing library
C++ high low Non-c series languages are complex Non-c languages use a glue layer
Rust high low Non-c series languages are complex Non-c languages use a glue layer
KMP In the low simple simple

conclusion

In short, wherever C++ cross-platform solutions are used, Rust can now be used instead. Rust has a more modern syntax, more secure code, and a better cross-platform ecosystem, making it particularly suited for handling some common data-layer logic.