Original link: dynamic Flutter framework "Thresh", now open source
I. Introduction
Since the birth of the mobile technology stack, its double-end development cost and release efficiency have been widely criticized. In order to solve these problems, front-end cross-end technology has been constantly trying, hoping to develop once, run on multiple ends, and release quickly. During this period, it has experienced several stages of technological development.
first stage: represented by H5, based on webview rendering
It only needs one development to run on both ends, which solves the problem of low development efficiency. However, webview has serious performance problems, and the user's interactive experience is significantly different from native rendering.
Phase 2: Represented by RN and Weex, front-end technology stack development, Native rendering
These solutions are developed using front-end technology and finally mapped to Native component rendering. Compared with the H5 solution, the user experience has been greatly improved. However, the plan at this stage also has shortcomings. Because the rendering of the framework still relies on the dual-end Native components, there are problems of inconsistency in the dual-end experience and platform compatibility. In extreme cases, the development cost even exceeds the dual-end Native development.
Stage 3: Flutter, self-drawn engine rendering
Based on the Skia rendering engine, Google launched the Flutter cross-platform framework, which supports three platforms of Android/iOS/Web (especially the release of 2.0 supports all platforms).
Based on the self-drawing engine, Flutter smoothes out the differences between various platforms, and truly achieves one development and multi-terminal operation. The industry also has high hopes for Flutter to completely solve the problem of cross-end development. However, Flutter is not perfect, its dynamic capabilities are insufficient, and it cannot be released as quickly as H5, RN and other technologies.
In order to solve the problem of insufficient dynamic capabilities, the front-end team of Manbang has been exploring the dynamic capabilities of Flutter since 2019. It has developed a dynamic Flutter framework, and has continuously optimized and iterated internally. It has launched 20+ pages, including core page order details. , cargo owner details, navigation maps, etc., and will be open sourced at the end of 2020.
Second, the dynamic thinking of Flutter
The original intention of the Thresh project is to provide a completely cross-end dynamic solution based on Flutter, with performance that can reach or even be better than React Native, plus its multi-end rendering consistency and the upcoming Google Fuchsia system The default development language is Flutter , all suggest that Thresh's future will be full of imagination.
2.1. Dynamic common solutions
To realize the dynamics of Flutter, you usually need to consider the following points:
- Flutter compilation product replacement
Google originally planned to launch the Code Push solution in 2019, but later gave up. There are two main reasons: violating the regulations of the app store and security considerations; but at present, android can be dynamic through product replacement, but iOS cannot do it. .
- Componentized construction
Some core general components are defined through Dart, and the page JSON assembled from the existing component list is delivered on the platform, and then rendered into a page by parsing on the end. This solution can satisfy light interaction scenarios, but can only support limited dynamics.
- Custom Dart Transformation + Dynamic Logic Mapping
By customizing a set of Dart specifications and generating JSON through a converter to achieve dynamic updates, the performance loss is small, but the logic dynamics need to be embedded in advance, and front-end development students need a certain learning cost.
- Custom DSL+ depends on dynamic execution of JS engine
Similar to RN/Weex, the conversion idea is run through the interpretation of custom dynamic UI description + JS engine, and finally a page is constructed and dynamic logic is executed. This solution is very friendly to front-end development, with zero learning cost, but there will be some performance loss due to running in the JS engine.
2.2, the choice of Thresh
In the actual usage scenario of Manbang, rapid business iteration requires both Android and iOS to support dynamics, so the idea of product replacement cannot completely solve the problem. Later, I considered using the idea of componentization. Although splicing multiple business components can build a page, the drawbacks are also obvious, which cannot be realized when complex interaction logic is used. In addition, although the custom Dart description UI scheme meets the requirements of dynamic update, the logic dynamic is still not strong, and Dart development has a certain learning cost for front-end development students.
In the end, considering factors such as development efficiency, learning cost, multi-end performance and consistency, we chose a custom JS description UI + JS engine interpretation, running and conversion ideas, React-like syntax structure, and JS/TS as the development language.
3. Realization principle
3.1. Principles of building Dart pages
The basic unit for describing the composition of views in Flutter is Widget. Each Widget only contains the configuration information of the current widget. It is a lightweight data structure that can be efficiently created and destroyed. And many Widgets are combined together to build a WidgetTree that contains all the information of the view. After that, Flutter will generate ElementTree from WidgetTree, and then generate RenderObjectTree from ElementTree. Element in ElementTree will hold its corresponding Widget and renderObject at the same time.
Among the three trees, WidgetTree will be frequently created and destroyed, but ElementTree and RenderObjectTree will only change when the state changes. ElementTree is responsible for element update and diff, and RenderObjectTree is responsible for actual layout and drawing.
The core idea is to construct the first tree widget among the three trees in Flutter's page rendering logic through JS. Among them, the JS and Flutter layers must complete the basic component mapping, and then generate the UI description through the JS engine, and pass it to the UIEngine of the Dart layer. The UIEngine converts the UI description into a Flutter control, and finally renders it into a page.
The Thresh framework has completed the definition and development of common basic components, which can support access to more than 95% of business scenarios. The grammar definition rules support React, allowing zero-cost access to front-end developers. The list of currently supported components and some of their properties are as follows:
3.1.1, Flutter initialization
Flutter starts the program execution from the main() function, which mainly completes the following tasks:
- Establish a communication channel MethodChannel with Native to ensure that all communications can be received and sent;
- Establish a distribution channel for all processing methods when a message is received to ensure that all legal communications can be correctly processed in Flutter, and at the same time send the media data of the current device to JS through MethodChannel;
- Register interception functions to convert JSON to Widget after receiving rendered JSON data;
- Finally, the initial hosting page of the Flutter App is established. The page will be in a waiting state until it receives a message from JS to display the page; at the same time, a ready message is sent to JS, indicating that the Flutter environment is ready to display the page.
3.1.2. Generate WidgetTree
According to all the interception functions registered for Widgets in Flutter, a set of corresponding atomic components will be provided in JS to convert components between two different DSLs. The construction of UI in JS is realized by JSX, which draws on the writing method of React.
By building the UI description layer in JS, and then converting the UI description into a JSON format string, it is sent to Flutter via Native, and Flutter parses the JSON string to create a corresponding WidgetTree and perform subsequent rendering operations.
3.1.3, JS and Flutter communication
Before the JS code is executed, Native will register two communication methods in the execution environment of the JS code, one is the channel for JS to pass messages to Flutter, and the other is the channel for Flutter to pass messages to JS. Through these two channels, all data can be transferred between JS and Flutter (described in detail in Chapter 3.2 later).
3.1.4. Building Flutter pages
When the data conversion of all links is completed, ModelTree & WidgetTree will be obtained, ModelTree will hold and cache WidgetTree, and finally build a Widget page and render it. The page construction and rendering process is mainly:
After Flutter receives the rendered JSON data, it will recursively traverse from the bottom to parse each independent rendering data node into a Model object. The Model will hold all the rendering data and associate its own parent node; at the same time, the Model will carry all the rendering data, generate its corresponding Widget instance through the Widget interception function, and hold the Widget instance.
For example, the <Container />
component in JS will be created as a widget instance named DFContainer after the interception function in Flutter. Widgets such as DFContainer are a set of custom components encapsulated by atomic components provided by Flutter.
When creating a Widget through the model, if it finds isStateful = true
, it will wrap a StatefulWidget in the outer layer of the Widget instance, and let the model hold the StatefulWidget and its state for later update operations. That is to say, if a model has isStateful = true
, it will have the characteristics of Widget & statefulWidget & state at the same time.
During the traversal, the original JSON data will be converted into two trees - ModelTree & WidgetTree. Each node in WidgetTree will be held by the corresponding node in ModelTree.
For the page displayed for the first time, the created WidgetTree will be used to directly replace the content of the hosting page created during initialization; if not the home page, a new page will be created and displayed using WidgetTree directly through Navigator.push(). The whole process is as follows:
3.2. Communication mechanism
JS and Flutter are completely independent ends that depend on Native: the data operation and flow in JS will not directly affect the rendering of Flutter pages; the rendering process of Flutter will not block the code execution of JS.
In order to connect the two completely independent, we found a medium that can not only connect with JS, but also pass messages with Flutter - Native. By passing a message from one end to Native, and then completely pass the native to the other On one end, the communication between JS and Flutter is realized.
The dynamic Flutter framework is mainly composed of these three parts. Each part handles different logic and binding event communication to update the rendering page and event response. Its core rendering communication process: Flutter ⇋ Native ⇋ JS .
3.2.1. Build a three-terminal communication link
When Flutter is initialized, Flutter will establish a communication relationship with Native through methodChannel. MethodChannel is a two-way communication link, which can not only receive native messages in Flutter, but also actively send messages to Native.
At the same time, Native will inject a method into the JS context before executing the JS code. We name this method methodChannel_js_call_flutter to enable JS to pass messages to Flutter. Therefore, the communication link in Flutter dynamization is as shown below.
From the above two links, it will be found that the message from JS to Native can reach Flutter smoothly; but there is no direct communication link between Flutter and JS, and it is interrupted in Native. In order to solve this problem, JS exposes a method named methodChannel_flutter_call_js in the context. The parameter of this method is the message content, so that Native can directly call this method to pass the message to JS.
3.2.2. "Half-duplex" communication process
In Thresh, almost all three-terminal communication needs are "half-duplex". The "half-duplex" here refers to that when one party acts as a message sender, it cannot get the message receiver's feedback through the channel currently delivering the message. This means that when the sender sends a message, it will end their communication behavior, and they don't need to care whether they will get feedback, and in fact, there will be no feedback.
Based on the above situation, all communication links in Thresh will use this mode for communication: the message sender only needs to pass the data and does not need to care about the callback, and the message receiver only needs to process the data and does not need to return the processing result. This mode is more convenient to manage and constrain the communication across the three terminals, and also makes Native a complete data transfer station. Otherwise, in addition to transmitting data, Native also needs to process the feedback of the results. That is, [Data Transmitter] -> [Data Transmitter] -> [Data Receiver] is one-way.
However, not all communications do not require feedback. For example, a double-ended communication link bridge that communicates with Native needs to obtain the processing result of the Native after sending a communication message to the Native. In this case, simple and crude one-way communication will not directly meet the demand. However, if it is replaced with "full-duplex" communication with callback, so that the result can be received on the same communication channel, the original communication mode will be destroyed, and it will also increase the difficulty of communication management.
In order to solve the communication feedback problem in the "half-duplex" communication mode, we add an identifier to each communication that needs feedback on the transmitting side, and then cache the feedback processing method through the identifier; After carrying the identifier and passing the processing result as a new message to the original sender through another communication channel (in this new channel, the original data sender and the receiver will exchange identities), the sender will be based on the identifier. The operator finds the processing method in the cache and executes the processing logic.
3.2.3. Establish a reliable message channel
The communication between JS and Flutter is the cornerstone of Flutter's dynamism, and the success of the first communication is the primary condition for the successful establishment of communication.
Since all cross-three-terminal communication is "half-duplex", and the environment preparation of JS and Flutter are completely independent of each other, this also leads to the fact that if the other party sends a message before the environment preparation of either party is completed, it will There is a situation where the party whose environment is not completed cannot receive the message, thereby affecting all subsequent communications, resulting in communication interruption or confusion.
In order to solve this situation, the following strategies are adopted in JS and Flutter to ensure the smooth execution of the first communication (A/B refers to either side of JS and Flutter):
- A will send a notification to B immediately after the environment preparation is completed;
- If B is ready, it will reply a notification immediately. After A receives the reply notification, it will mark that the environment of both parties has been established, and follow-up communication can be carried out;
- If B is not ready, A will not receive any reply until B is ready, at which time A/B identities are swapped and it will go back to step 1.
3.3. Component update and event delivery
3.3.1. JS event triggering and delivery
After the event function in JS is converted into an id, the id will also be carried into Flutter together with the name of the page to which the node belongs and the node id, and finally these three pieces of information will be packaged into an event function in Flutter.
When an event is triggered in Flutter, this function will be triggered first, which will send a message to JS with the page name, node id, event id, and event parameters. After JS receives the message, it will first find the node that triggered the event according to the page name and node id, then find the corresponding event in the node event pool through the event id, pass in the parameters and execute the event.
3.3.2, JS component update
Most of the purpose of triggering events is to update the content on the page. In JS, the basic unit of component update is the custom component.
When a custom component triggers setState(), the component is pushed into the update queue to wait for an update. Deduplication will be performed before the node enters the queue, and 16ms after entering the first component from the queue, the queue will perform an update operation. Other components to be updated that enter the queue within this 16ms will trigger the update together.
Before the actual update operation, the elements in the queue will be deduplicated from the parent node, that is, all the nodes to be updated will be obtained in turn, and the parent node of the node will be obtained upward. If its parent node exists in the current queue, then Remove the node to be updated from the queue, or keep it if it does not exist. This is done because as long as there is a parent component in the queue, the child component is guaranteed to be updated; the purpose is to perform the least number of operations, but update as many components as possible.
The update of components draws on the component update diff algorithm of React, but due to the introduction of the concepts of Flutter StatefulWidget and StatelessWidget, the diff algorithm of thresh.js is coarse-grained compared to the diff algorithm of React.
The same thing between the two is that each node will be compared to ensure that the state of each node is correct and eventually updated correctly.
The difference is that in addition to merging properties and states of nodes of the same type, React also inserts or deletes newly created or deleted nodes into the old node array. The basic unit of operation and update is atomic components; thresh.js will only pay attention to the same type of nodes that are still retained before and after the update. After the attribute and state are merged, the old node will be discarded directly, and the new node will be retained. Finally, the new node will replace the old node in the custom component to be updated. , and send an update message to Flutter using the updated custom component's data - the basic unit of update is the custom component.
3.3.3, Flutter component update
The update message sent by JS consists of two parts: the name of the page to be updated, the update node id, and the JSON data of the update node. When Flutter receives the update message sent by JS, it first repeats the step of converting json to Model to create ModelTree & WidgetTree. After that, it finds the Model that needs to be updated in the cache through the updated page name and node id.
Since the update takes the custom component in JS as the smallest unit, and each custom component will be created as a StatefulWidget in Flutter, the following operations will be performed after obtaining the old and new Models:
- Merge the rendering data of newModel, child node models and the newWidget it holds into oldModel;
- Update the oldWidget wrapped in statefulWidget to newWidget through the state held by oldModel;
- After completing the component update operation through state, Flutter will diff and re-render the updated component to ensure that the page can display the new content.
# Four, engineering
## 4.1, Thresh Architecture
The overall engineering architecture of Thresh is as follows:
As shown in the figure above, from bottom to top, CI/CD + basic services + monitoring and reporting support the Thresh business, and the top is the architecture diagram.
- X-RAY is the company's self-developed production and release platform, which supports the construction, distribution and operation and maintenance of Bundle packages.
- At the top is the overall Thresh architecture flow chart, including page development, DSL conversion, communication, etc., for building pages and logic.
Although the Thresh dynamic cross-platform solution has the advantages of high-performance rendering, consistency, development efficiency, and zero-cost access to front-end students in design, considering the future access of multiple business parties and improving the efficiency of development and debugging, the peripheral infrastructure of Thresh has been promoted. Construction, the following briefly introduces the development period, debugging period, and release period.
- development period
It supports plugin access, and business side access provides a set of template projects, which can quickly enter business development; in addition, Thresh is compatible with TS, which can integrate front-end development at a lower cost. - Commissioning period
By supporting HotReload mode and second-level compilation, the development and debugging efficiency is greatly improved. In addition, the debugging panel + dynamic debugging capability can also greatly assist in improving the debugging efficiency. - release period
Relying on the X-RAY grayscale publishing system developed by Manbang, it has the ability to dynamically publish in minutes, and can quickly support business and problem repair.
4.3, Thresh development integration
The development and integration of Thresh has formed a complete set of processes, covering tripartite integration, multi-service module access, development and debugging, etc., which involve many details, which are described in detail in the open source repository.
So far, Thresh's architecture design and development integration capabilities have been basically completed. Compared with other dynamic cross-platform development frameworks, Thresh has the following advantages:
- JS-based custom DSL with strong scalability and low learning cost
- Multi-end consistency, with a unified self-rendering engine skia, better cross-end compatibility adaptation
- Support Hot Reload, easy to develop and debug, compile in seconds
Support component-level UI refresh, excellent experience
- Provide a debugging panel during the development period to facilitate development
V. Conclusion
The basic principle of building a Flutter application through JS is not complicated, mainly the data processing in JS, the data conversion in Flutter, and the realization of the data flow channel in JS and Flutter. These solutions are similar, such as MXFlutter and Meituan Food Delivery MTFlutter. However, this solution seems to be relatively tasteless at present, which deviates from the original intention of Flutter's cross-platform involvement.
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。