QTalk is an IM communication tool in Qunar, which integrates many internal systems, such as OA approval, access control punch card, leave approval, conference room reservation, camel circle (camel factory friend circle) and other functions. It not only facilitates internal office communication and exchange, but also provides support for paperless office, process approval and so on.
A, First, take a look at QTalk’s original product framework
Before deciding to refactor Flutter, we took stock of the problems with the existing QTalk engineering architecture:
1. Big difference between each end: Android, iOS and QT development framework (a C++ desktop cross-platform solution) have great differences in the logic codes of the three ends, including Web loading logic and ReactNative page loading logic of the mobile end. When finding the root cause of the problem, the three ends will have different situations. The solutions are different.
2. Low efficiency of RESEARCH and development: three sets of codes need to be maintained. Under the existing human resources, it is already tight to ensure the complete function and launch on time, and various online problems need to be solved in time.
3. Poor architecture level: the architecture design of each end is different and unclear, and the direction of data flow push is complex, which are the two main problems.
4. High complexity of native code: the blue area represents the code that uses the capabilities of the native platform. They are not reusable among different platforms and are prone to adaptation problems in version upgrade, and are prone to rework when implementing requirements with inconsistent performance at each end.
In summary, we decided to refactor QTalk with the goal of reducing development costs, improving development efficiency and reusing code across platforms as much as possible.
Why Flutter
The advantage of Flutter is that it has high rendering performance and smoothen all differences. This is because Flutter uses an autonomous rendering engine to control the rendering process and ensure efficiency. Flutter is like an application running inside a game engine.
In the past, some people hoped to use Cocos2D or Unity to make applications, so as to achieve cross-end consistency and save time. However, game engine rendering is frame by frame, and native (iOS and Android) rendering is business driven, that is, rendering is performed only when the model is changed. By comparison, game rendering consumes too much performance. The package size has been increased many times, but Flutter has basically solved these problems by modifying the rendering process. Just like native rendering, Flutter creates a rendering tree. Redrawing will only start when the rendering tree changes, and drawing will generally only take place in the changed area.
QTalk development resources are scarce, so a cross-platform framework is needed to improve efficiency. At the same time, QTalk is also the main way of communication in the company, so the page fluency needs to be guaranteed. QTalk is commonly used for long and short connections, long lists, Web, etc. There is also a good support for Flutter officials and the community. Hybrid development was also supported by the official engine in Flutter 2.0, so we decided to use Flutter to develop a new version of QTalk.
QTalk code framework for Flutter
As shown in the figure, the data layer comes from push, HTTP or long connection, and becomes IMMessage type object in Flutter after processing. The data layer processes database storage and interaction logic layer in each module, and after processing, the data can be distributed to various interfaces for use in subscriber mode.
Compared with the old architecture, the new architecture has these advantages:
-
The business performance layer basically smooths out the differences of each end. We use a set of code to achieve a 5-end UI (Android, iOS, Mac, Windows and Linux), and the overall UI code reuse rate reaches more than 80%, avoiding the extra work of UI adaptation caused by the original performance differences of each end.
-
Dart implements all functions of the logic and data layer with the exception of a few capabilities (such as push) that must use native code, reducing the time required to maintain and make new requirements by about 50%.
-
In the whole process of APP data flow, one-way data flow is used for all data about the interface, and reasonable stratification is used to reduce the complexity of the application. All components do not need to save the state and are only responsible for rendering according to the data source.
4. Problems encountered by Flutter reconstruction of QT mobile terminal
- Use hybrid stacks in QTalk
Most of the QT pages are IM business pages that can be reconstructed using Flutter. However, some other pages are not suitable for the IM team due to frequent updates. For example, the QT discovery page is developed using ReactNative, and QT is only displayed as an entrance. Therefore, we need a set of technical solutions for mixing ReactNative pages and Flutter. Currently, there are two mainstream Flutter technology stacks:
-
Flutterboost single-engine hybrid page development.
-
The official FlutterEngineGroup released in Flutter2.0 uses multiple engines to solve problems, optimizing memory usage and data sharing.
In QT, we tried both mixtures and found that each has its own advantages and disadvantages, as follows:
Flutterboost | FlutterEngineGroup | |
---|---|---|
advantage | Ioslate shared memory, convenient data transfer between pages | Official support, code intrusion is small, performance is almost unaffected, adding an engine only adds 180K memory |
disadvantage | The upgrade cost is high, adding a page consumes a lot of iOS memory (the new version has been improved), and the engineering structure needs to be changed according to boost | The IOslate layer cannot share memory, so it is troublesome to call each other directly |
After trying the transformation project of two methods and feeling the shortcomings of both methods, we thought of taking advantage of the new features of Flutter2.0 mixed view and taking our third route: Use PlatformView to mix the ReactNative page with the Flutter page, using the Flutter routing capability to support the page jump.
The advantage of this is that the life cycle of the ReactNative page is coupled inside the ReactNative page in both the mobile terminal and the Flutter perspective, and can be treated as a simple view when used. Therefore, without interfering with the life cycle of the Native page, The Native end is only used as a bridge to transfer the parameters of the Flutter and the ReactNative page. The original interaction between the ReactNative page and the Native page remains unchanged. Only PlatformChannel parameter transfer between Native and Flutter was added.
Examples are as follows:
‘//Flutter calls native’
const
MethodChannel _channel =
“const MethodChannel(“’com.mqunar.flutterQTalk/rn_bridge’“); The channel ` ` / / registration
_channel.invokeMapMethod(``'onWorkbenchShow'``, {});
// Call Flutter native
_channel.setMethodCallHandler((MethodCall call) async {
``var classAndMethod = call.method.split(``'.'``);
``var className = classAndMethod.first;
``if
(mRnBridgeHandlers[className] == ``null``) {
``throw
Exception(``'not found method response'``);
` `}
``RNBridgeModule bridgeModule = mRnBridgeHandlers[className]! ;
``return
bridgeModule.handleBridge(call);
`}); ` `
Mix the ReactNative page View transmitted from Native and FlutterView to generate a new page on the Flutter side. This page can accept the call of the Flutter stack. This avoids the adaptation problem of all ends calling each other on the routing level.
“Widget getReactRootView(`
``ReactNativePageState state, Dispatch dispatch, ViewService viewService) {
// Android and iOS are handled separately
``if
(defaultTargetPlatform == TargetPlatform.android) {
``return
PlatformViewLink(
``viewType: VIEW_TYPE,
``surfaceFactory:
``(BuildContext context, PlatformViewController controller) {
``return
AndroidViewSurface(
``controller: controller as AndroidViewController,
``gestureRecognizers: ``const
<Factory<OneSequenceGestureRecognizer>>{},
``hitTestBehavior: PlatformViewHitTestBehavior.opaque,
` `);
` `},
``onCreatePlatformView: (PlatformViewCreationParams params) {
``return
PlatformViewsService.initSurfaceAndroidView(
``id: params.id,
``viewType: VIEW_TYPE,
``layoutDirection: TextDirection.ltr,
``creationParams: state.params,
``creationParamsCodec: StandardMessageCodec(),
` `)
` `.. addOnPlatformViewCreatedListener(params.onPlatformViewCreated)
` `.. create();
` `},
` `);
``} ``else
if
(defaultTargetPlatform == TargetPlatform.iOS) {
``return
UiKitView(
``viewType: VIEW_TYPE,
``creationParams: state.params,
``creationParamsCodec: ``const
StandardMessageCodec());
``} ``else
{
``return
Text(``"Placeholder"``);
` `}
`} ` `
Thus we added only a small amount of code, which solved the problem that the Flutter hybrid stack was inefficient and difficult to develop.
- QT data transfer
In the initial stage of QT Flutter, provider, BLoC, mobx and other data flow management schemes have been tried. Their advantages and disadvantages are listed in a table:
provider | BLoC | mobx | redux | fish-redux | |
---|---|---|---|---|---|
advantage | Simple, high performance, official support | Processing asynchronous events is efficient and hierarchical | State operation is simple, code less, easy to learn | Single data flow, separation of view and business logic | Based on the advantages of Redux, it has the function of automatically merging reducer and isolating components, and has strong scalability |
disadvantage | Easy to write logic in the view and easy to couple the view to the model | State sharing is easy to write wrong logic | Data merging is inefficient, and too liberal a usage makes code coupling easy | Conflict between the centralization of redux store and the division and control of page components 2. The reducer needs to be merged manually | It’s a little more cumbersome to write than mobx |
Here are some specific experiences:
-
Provider is the data management solution initially selected, which is provided by the authorities. When used, the Model class needs to inherit from ChangeNotifier, and uses Consumer to wrap the components that need to be changed. A student who has just developed Flutter can’t separate the page logic from the page UI. This leads to serious coupling, requiring code specification, and if the Consumer package scope is too large, it can affect performance and cause unnecessary lag.
-
In Bloc’s demo, we saw that the whole process was asynchronous, separating the logic from the UI. However, introducing this solution is more intrusive to the code than Introducing Provider, while the StreamProvider can fully realize Bloc’s function in the specified code specification. In addition, compared with the redux-type management scheme, it does not incorporate the tedious writing method and restrictions of store, and foreshadows the confusion when sharing data or multiple data affect the same view at the same time, so we did not adopt it.
-
In the Demo of Mobx, we found that it has its own advantages. It does not need to write notify code when updating data. However, it is a two-way data binding with a large degree of freedom, and it is easy to confuse the action order of GET and set without code specification. In the case of a large number of data changes, its data transfer and merger will cause the efficiency of the program.
-
In redux scheme, pure function dispatcher is used to modify state. Compared with two-way binding, it will separate the user’s operation of updating data and using data. There will be template specification users, but the operation of combineReducers will make page reuse difficult and change a lot.
-
Fish-redux is a customized and modified version of Redux, with fine logical isolation granularity, which automatically realizes the function of merging reducer and decoupling pages. In addition, it also has some problems, such as the use of global variables will coupling all the pages used and cumbersome writing method.
-
We wanted to use the fish-redux globalstore as a trigger for long links and HTTP interface callbacks. We discovered that the fish-redux globalstore would need to bind the page in route to be used. Every page that uses the global attribute also needs to add the attribute to accept binding, which defeats the purpose of page partition and causes additional development effort when reusing the page due to its relationship with global.
Based on the above experience, we finally decided to use two ways to manage and transfer data.
- Fish-redux
Fish-redux is used in the logical layer of IMMessage Module object logic construction and presentation layer to make QT’s original multi-directional complex data architecture neat and easy to sort out. Each page level development is disintegrated into independent pages. The extension can use connector plug and play. Collaborative development reduces the likelihood that code will clutter up at the page level due to staff changes.
As shown in the figure, when we write the code, we only need to care about the one-way data flow inside each page, without being aware of the page data merge, and each page consists of 5 files: Action, Effect, Reducer, State and View separate data processing and page refresh in various ways, and maintain the code order from the engineering level and page level.
- Eventbus
Used for communication between database objects and IMMessage Module objects, data layer and logic layer.
Event bus triggers events and listens to events. It is a singleton mode for managing and distributing data. Lightweight and globally available, it can transfer data without rendering context objects, and divide and conquer data logic and business.
3. The ListView
The current version of the Listview of Flutter does not prefetch the height of each item based on the model. Instead, the height of the item is counted after the rendering is complete. This has several consequences.
A. ListView does not support jumping to the corresponding index. There is no simple way to jump to the corresponding index if the item is not of the same height.
B. If the jump position is not in the slippable range, the ListView can only try to jump first. If the final jump position is greater than the slippable range, the ListView will produce a bounce.
C. scrollToEnd method: if the item at the end of the List is not in the screen, the position of the index at the end of the List is estimated according to the average height of items in the screen. After sliding, if the final sliding position is not on the last item, the second or even third jump is required.
Solution: The scrollable_POSItionED_list control is introduced, which essentially generates 2 listViews. One ListView is responsible for calculating the height, and the other ListView will actually render to the interface. Calculate the final index height, and then the second List jump to the exact position, and for the problem of bounce, we need to modify the ListView, in the jump process found displacement is too large, immediately correct, example code is as follows:
void` `_jumpTo({
@required
int
index, “double
offset}) {`
.
// Use offset
``var jumpOffset = ``0
+ offset;
``controller.jumpTo(jumpOffset);
'// Find overflow after rendering, fix it
``WidgetsBinding.instance.addPostFrameCallback((ts) {
``var offset = min(jumpOffset, controller.position.maxScrollExtent);
“if (controller.offset ! = offset) {
``controller.jumpTo(offset);
` `});
`}
- Accurately obtain iOS keyboard height
The height of the iOS keyboard is not calculated correctly, causing the height inconsistency between the keyboard and the emoticon, which makes the chat interface shake
Cause: Because the bottom height of some models in safeArea is not 0, the general writing method will directly write the chat page into a safeArea, but when the keyboard pops up, the bottom of safeArea will clear 0, causing the keyboard to jump.
Solution: After initializing the App, record the bottom height of safeArea locally. Remove the safeArea package from the chat interface, use the height of the local record, and add the height to the input box at the bottom to avoid overlap with the iOS navigation bar.
Dart code cannot debug breakpoints with Native code
Reason: Dart and Native code are compiled separately. At runtime, only code from one side can be linked, and the compiler cannot parse libraries generated by the other side.
Solution: Start the App from Native in Xcode or Android Studio, then open the IDE or terminal that compiled the Dart code, and use the Flutter attach command to attach your Dart code to the running App. At this point, you can debug Native and Dart code at the same time.
Fifth, QT desktop problems and solutions
- Reuse of mobile interface
As mentioned before, our data management scheme can decouple each page, and the page as a whole can be reused by other components. The desktop side takes advantage of this design mode, and the mobile side view can be integrated into a desktop side main page only by adding connector to each page on the mobile side. The corresponding logical layer only needs to be adapted based on the features of the desktop, for example, different apis are called and the desktop supports right-clicking.
In the figure, Page and Component are the basic logic and UI units provided by Fish-Redux. They can be arbitrarily combined with each other. They meet the requirements of QTalk multipurpose REUSE of UI and logic and are also an important basis for selection.
The page assembly process can be implemented by the following pseudocode: ‘// each sub-page adapter code’
SessionListComponent.component.dart
SessionListState
{
``....
}
SessionListConnector
{
This component's state comes from the state property of the upper component
``get
` ` {
``return
HomePCPage.scState
` `}
'// called after its properties have changed to synchronize the state of the upper component
``set
` ` {
``HomePCPage.scState = ``this``.state;
` `}
}
// Desktop home page synthesis code
HomePCPage.page.dart
HomePCPage
{
``....
``dependencies:
// Overrides the + sign to add child component properties, returning a component with connector for upper page use
``slot:SessionListConnector() + SessionListComponent(),
`} ` `
- Multi-window creation, mutual message transmission, call PC native capabilities
There are many native platform-related capabilities that Flutter-desktop does not have on PC, such as multi-window, screen recording, Web use, drag and drop file sharing, Menubar configuration, etc
Solution: Introduce NativeShell framework, adopt multi-engine approach to solve the multi-window problem encountered on PC side, change the engineering structure, add a rust class to manage Windows before dart starts main function, Call each platform system library in rust to write various languages (c++, c#, oc, etc.) into system API into rust type files to reduce platform differences.
Adaptation to NativeShell has also encountered many problems, here are two examples:
A. An empty security error occurs in the packaging script
Cargo is the rust package manager. NativeShell uses Cargo for desktop packaging. By default, NativeShell does not allow libraries without null safety to be added to the project. We rewrote the packaging script and added a non-null judgment on Flutter compilation, which eventually enabled us to print Mac and Windows packages in rust.
B. Mac client packaging problem
In the packaging process of NativesShell, each Window will generate a sub-project, and the shell project directly references the sub-project directory. The final package will contain a large number of intermediates, resulting in a very large package. We changed the process, only adding DLLS and framework generated by the sub-project into the final product. A normal size bag was punched out. We also communicated with the author, proposed PR, and eventually incorporated the code and suggestions into the producer packaging tool.
- Multiple Windows on the PC send messages to the main window at the same time, causing the Dart main ISOLATE command queuing
To explain this, let’s take a look at the Flutter event cycle:
The Dart application has an event loop and two queues: Event Queue and MicroTask Queue.
- Event Queue: Contains all external events: I/O, mouse click, draw, timer, messages in the Dart ISOLATE, and so on.
- Microtask Queue: Event-handling code sometimes needs to do tasks after the current event and before the next event.
The Event Queue contains events from the Dart and the system. Currently, the MicroTask Queue contains only events from Dart. As shown below, when main() exits, the Event loop starts working. The first is to execute all microtasks, which is actually a FIFO queue. Next, it will fetch and process the events in the first Event Queue. Next, the execution cycle begins: all microTasks are executed, followed by the next event in the Event Queue. Once both queues are empty, that is, without events, they can be processed by the host (such as the browser).
If the Event loop is executing events in the MicroTask queue, event processing in the Event Queue will be stopped, which means that drawing images, handling mouse clicks, handling I/O, etc., will not be possible, even though you can know in advance the order in which tasks will be executed. However, you have no way of knowing when the Event loop pulls the task from the queue. Dart’s event handling system is based on a single-threaded loop model rather than a time-based system. For example, when you create a delayed task, the time is set at a time you specify. However, the event in front of it is not processed, and it cannot be processed.
Most business logic can be reused between PC and mobile terminals, but there are still some differences in rendering process. At most, multiple sub-windows send messages to the main window at the same time, and these messages will be added to the Event queue in the main ISOLATE. If the number of messages is too large, There are too many events in the event queue of the main ISOLATE, which causes the page of the main ISOLATE to stall.
In view of the above situation, we added a layer distribution to solve this problem, the original logic control after the processed data, send a notification to the distribution layer, distribution layer will statistics the previous rendering frame to the main isolate operation request quantity, if more than the threshold value to add to the command queue, waiting for the next rendering frame to send the request again, If the number of commands in the queue is too long, the queue is suspended and a failure notification is sent to the sub-ISOLATE. The sub-ISOLATE can resend the message.
6. Summary of technical achievements
- Product Development Direction:
A. The development volume of the previous three projects is changed into one, and the development period is shortened by more than half;
B. After the completion of development, the consistency of each end is significantly improved, and the probability of the difference between each end in reverse work and online is reduced.
- Technical architecture Direction:
A. Sorted out the previous coupling between logical layer and business layer of each end, solved the problem of code maintenance caused by multi-direction data flow, and established a Flutter code engineering specification;
B. Solved all kinds of native adaptation problems of Flutter in building chat page business layer, and accumulated experience in Flutter development for the team;
C. Solve the problem of mixing stack and original project pages, and explore the advantages and disadvantages of various mixing methods with native (iOS, Android and other platforms) RN Web and other types of pages. I believe that Flutter will be more smoothly introduced into other businesses of the company in the future.
- Performance data and user experience:
A. The size of iOS package is reduced from 200M to 117M, and the size of Android package is reduced from 44.9m to 27.5m; \
B. Memory water level of the mobile terminal Flutter version is basically the same as that of the Native version.
C. The speed of opening the APP has been improved. The average cold startup time of the original QTalk APP for iOS and Android was 2.6s, but now it is 1.5s, an increase of 42%.
Seven, the direction to do next
-
Mobile has been launched – need to solve long connection stability and complete the business code, improve the function and develop pipline suitable for QTalk.
-
The PC side is not yet developed: it is necessary to complete the Windows side database selection and screen capture, keyboard function, SDK access of native side, and access to appropriate log collection tools.
-
Participate in the migration and modification of Flutter by large clients, using our experience to solve problems of mixed stacks, long lists and data streams, and device side adaptation.
The journey of Flutter exploration is not over. The QTalk team is still working on it. We will find opportunities to talk to you about Flutter technologies in the future.