QTalk is an IM communication tool within Qunar.com, and integrates many internal systems, such as OA approval, access control punching, leave approval, meeting room reservation, camel circle (camel factory circle of friends) and other functions; it is convenient for internal office communication, At the same time of communication, it also provides support for paperless office, process approval, etc.
First, the original product framework
Before deciding to refactor Flutter, we took stock of the problems of the existing QTalk engineering architecture, mainly as follows:
- Big differences between each end : Android, iOS and QT development framework (a C++ desktop cross-platform solution) The three-end logic code is very different, the representative ones are Web loading logic, mobile React Native page loading logic, etc., check At the root of the problem, there will be different situations on the three sides, and the solutions will be different.
- Low R&D efficiency : 3 sets of codes need to be maintained. Under the existing human resources, it is already very difficult to ensure that the functions are completed and launched on time, and various online problems need to be solved in time.
- architecture level is poor : The architecture design of each end is different and unclear, and the complicated direction of data flow push is the main two problems.
- The complexity of the native code is high : The blue area represents the code that uses the native platform capabilities. They are not reusable between platforms and are prone to adaptation problems during version upgrades. When implementing requirements A case of inconsistent rework.
In order to reduce development costs, improve development efficiency, and reuse code on various platforms as much as possible, we decided to refactor QTalk.
2. Why choose Flutter
The advantage of Flutter is that it has high rendering performance and smoothes out the differences at all ends. The root cause is that Flutter uses its own rendering engine to control the rendering process and ensure efficiency, which is equivalent to an application running in a game engine.
In the past, some people hoped to use cocos2d or unity to make applications to achieve cross-end consistency and save man-hours, but game engine rendering is frame-by-frame rendering, and native (iOS, Android) rendering methods are business-driven, that is, in the case of model changes In contrast, the rendering method of the game consumes too much performance and the package size increases many times. Flutter basically solves these problems by modifying the rendering process. Flutter will generate a rendering tree when rendering, just like native rendering. , only when the rendering tree changes, repainting will start, and painting generally only occurs in the area that has changed.
Due to the shortage of QTalk development resources, a cross-platform framework is needed to improve efficiency. At the same time, QTalk is also the main way of communication in the company, and the fluency of the page needs to be guaranteed. The long and short connections, long lists, Web, etc. commonly used by QTalk, the Flutter official and community also have a good support. Hybrid development is also supported by the official engine in Flutter 2.0, so we decided to use Flutter to develop the new version of QTalk.
3. Flutter version of QTalk framework
It can be seen that the data layer comes from push or http or long connection. After the processing is completed, it becomes an IMMessage type object in Flutter. The database storage and interaction logic layer is processed in each module. After the data is processed, the subscriber mode can be used to distribute to Each interface is used, and the upper UI layer is developed using Flutter, which shields the differences of each layer and reaches the maximum.
Compared with the old architecture, the new architecture brings the following advantages:
- The business presentation layer has basically smoothed out the differences between the various ends. We have used a set of codes to implement the UI of 5 ends (Android, iOS, Mac, Windows, Linux), and the overall code reuse rate of the UI has reached more than 80%, avoiding the original end-to-end UI. Additional workload for UI adaptation due to performance differences.
- Except for individual capabilities (such as push), the logic and data layer must use native code, and the rest of the functions are implemented in a unified manner in Dart, which reduces man-hours by about 50% when maintaining and making new requirements.
- In the entire APP data flow process, all data about the interface uses a one-way data flow, and at the same time, it is reasonably layered, which reduces the application complexity. All components do not need to save the state, and are only responsible for rendering according to the data source.
Fourth, the problems encountered
4.1 Hybrid stack
Most of the pages in QT are IM business pages that can be refactored using Flutter, but some other pages face the problem of frequent updates, and the maintainer is not suitable for the IM team. For example, the QT discovery page is developed using ReactNative, and QT is only displayed as an entrance. , so we need a technical solution that mixes ReactNative pages and Flutter. Now there are two mainstream mixed technology stacks in Flutter:
- Flutterboost single engine realizes mixed page development.
- The FlutterEngineGroup officially released in Flutter2.0 uses multiple engines to solve problems and optimizes memory usage and data sharing.
We have tried both methods in QT, and finally found that they have their own advantages and disadvantages, as shown in the following table:
plan | Flutterboost | FlutterEngineGroup |
---|---|---|
Advantage | ioslate shared memory, easy to transfer data between pages | Officially supported, code intrusion is small, and performance is hardly affected |
disadvantage | The upgrade cost is high, the consumption of adding a page is relatively large, the memory consumption of iOS is large (the new version has improved), and the engineering structure needs to be greatly changed according to boost | The ioslate layer cannot share memory, and it is troublesome to directly call each other |
However, instead of using the above two solutions, we took advantage of the new features of Flutter2.0's hybrid view and took our own third route: using PlatformView to mix React Native pages with Flutter pages, and using Flutter's routing capabilities Support this page jump.
The advantage of this is that in the perspective of mobile and Flutter, the life cycle of the ReactNative page is coupled inside the ReactNative page, and it can be treated as a simple view when used, so we can do it without intervening in the life cycle of the Native page. Next, only the Native side is used as a bridge to pass Flutter and ReactNative page parameters. The original interaction between the React Native page and Native remains unchanged, only the PlatformChannel parameter between Native and Flutter is added.
The communication between Native and Flutter uses Channel, so we encapsulate as follows:
//Flutter 调用原生
const MethodChannel _channel =
const MethodChannel('com.mqunar.flutterQTalk/rn_bridge'); //注册channel
_channel.invokeMapMethod('onWorkbenchShow', {});
//原生调用Flutter
_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);
});
On the Flutter side, the React Native page View passed from Native is mixed with FlutterView to generate a new page. This page can accept calls from the Flutter stack, and it is no different from the pure Flutter page when it communicates with other Flutter pages and participates in switching. The level avoids the adaptation problem of each end calling each other. The corresponding code to get the React Native page is as follows:
Widget getReactRootView(
ReactNativePageState state, Dispatch dispatch, ViewService viewService) {
//安卓与iOS分别处理
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");
}
}
In this way, we only added a small amount of code to solve the inefficiency and difficult development of the Flutter hybrid stack.
4.2 Data transfer
Flutter tried data flow management solutions such as provider, BLoC, and mobx in the early days. We have listed their advantages and disadvantages in a table.
provider | BLoC | mobx | redux | fish-redux | |
---|---|---|---|---|---|
Advantage | High performance, official support | High efficiency in processing asynchronous events and clear layers | Simple state operation, less code, easy to use | Single data flow, separation of views and business logic | Based on the advantages of redux, it has the function of automatically merging reducers, isolating components, and has strong scalability |
disadvantage | It is easy to write logic in the view and it is easy to couple the view and the model | It is easy to write wrong logic when state sharing | Data merging is inefficient, and too free usage can easily couple code | 1. The contradiction between the concentration of redux store and the division and conquer of page components 2. The reducer needs to be merged manually | Compared with mobx, it is more cumbersome to write |
Here are some specific user experiences:
- provider : provider is the data management solution originally selected, provided by the official, when using it, the model class needs to inherit ChangeNotifier, and use Consumer to wrap the components that need to be changed. It is difficult for a newly developed Flutter student to separate the page logic from the page UI. , resulting in serious coupling, code specifications need to be formulated, and if the scope of the Consumer package is too large, it will affect the performance if it is not careful, causing unnecessary freezes.
- Bloc : The logic and UI are separated, but the introduction of this scheme is more intrusive to the code than the provider, and the specified code specification StreamProvider can fully implement the function of Bloc. In addition, compared with the Redux type management scheme, it is not merged into the store. The cumbersome writing method and restrictions also pave the way for the confusion when shared data or multiple data affect the same view at the same time, so we did not use it.
- Mobx : The advantage of Mobx is that there is no need to write notify code when updating data, but it is two-way data binding and has a relatively large degree of freedom. In the absence of code specifications, it is easy to confuse the action sequence of get and set , and at the performance level, according to our tests, in the case of a large number of data changes, its data transfer and merging will reduce the efficiency of the program.
- Redux : In the Redux scheme, the pure function dispatcher is used to modify the state. Compared with the two-way binding method, it will separate the user's operation of updating data and using the data. There will be template specification users, but the operation of combineReducers will cause the page to be duplicated. becomes difficult to use and requires writing a lot of extra code.
- fish-redux : fish-redux is a customized and modified version of redux, with finer granularity of logical isolation, and automatically realizes the functions of merging reducers and decoupling pages. In addition, it also has some problems, such as the use of global variables will couple all applications. To the page, the writing is cumbersome and so on.
We began to want to use the fish-redux global store as a trigger for the callback of long links and http interfaces. During use, we found that the global store of fish-redux needs to be bound to the page to be used in the route first, and each uses the global attribute. The page also needs to add attributes to accept binding, which is contrary to the purpose of page division and rule. When reusing this page, it will also cause additional development due to the relationship with the global. Based on the experience of using the above state management framework, we finally decided to use two methods to manage and transfer data: Fish-redux and Eventbus.
Fish-redux
Fish-redux is used in the logic construction and presentation layer of the IMMessage module object of the logic layer, so that the original multi-directional and complex data structure of QT becomes neat and easy to sort out. When developing each page level, it is disassembled into independent pages, which can be extended. Using connectors to plug and play, collaborative development reduces the possibility of code confusion at the page level due to personnel changes.
As shown in the figure, we only need to care about the one-way data flow inside each page when writing code, and have no perception of page data merging, and each page consists of 5 files: Action, Effect, Reducer, State, and View. Use various methods to separate the data processing and page refresh, and maintain the order of the code from the engineering level and the page level.
Eventbus
The function of Eventbus is to communicate with database objects and IMMessage module objects, and the data layer communicates with the logic layer. The event bus is used to trigger events and monitor events. It is a singleton mode for managing and distributing data. It is lightweight and globally available. It can transmit data without the participation of rendering context objects, and divide and conquer data logic and business.
4.3 ListView transformation
When Flutter's current version of Listview generates each item, it does not prefetch the height according to the model, but counts the item height after the rendering is completed, which has several consequences.
- ListView does not support jumping by index, and there is no simple way to jump directly to the corresponding index when items are not of equal height.
- When jumping to a position that is not on the screen, because the ListView does not know whether the position is within the swipeable range, it can only try to jump first. If the final jump position is larger than the swipeable range, it will bounce.
- In the scrollToEnd method, if the item at the end of the List is not on the screen, the position of the end index is estimated according to the average height of the item on the screen. After sliding, if the final sliding stop position is not on the last item, a second or even three jumps are required.
The solution we solved is also very simple: introduce the scrollable_positioned_list control, which essentially generates two ListViews, one ListView is responsible for calculating the height, and one ListView will actually be rendered on the interface. When jumping, let the first List jump first, and calculate the final index height, and then the second List jumps to the exact position. For the problem of bouncing, we need to modify the ListView. During the jumping process, it is found that the displacement is too large, and it is corrected immediately. The sample code is as follows:
void _jumpTo({@required int index, double offset}) {
...
// 使用偏移量offset
var jumpOffset = 0 + offset;
controller.jumpTo(jumpOffset);
// 渲染之后发现溢出,进行修正
WidgetsBinding.instance.addPostFrameCallback((ts) {
var offset = min(jumpOffset, controller.position.maxScrollExtent);
if (controller.offset != offset) {
controller.jumpTo(offset);
});
}
4.4 Get iOS keyboard height
The height calculation of the iOS keyboard is inaccurate, resulting in inconsistent heights when switching keyboards and expressions, making the chat interface jittery
Reason : Because some models have a safeArea bottom height not 0, the general writing method will directly write the chat page into a safeArea, and the safearea bottom will be cleared to 0 when the keyboard pops up, causing the keyboard height to jump.
Solution : After initializing the App, record the bottom height of the safeArea locally, then remove the safeArea package in the chat interface, use the height recorded locally, and increase the height of the bottom input box to avoid overlapping with the iOS navigation bar.
4.5 Mixed project breakpoint debugging
Reason : Dart and Native code are compiled separately. Only one side's code can be linked at runtime, and the compiler cannot parse the library generated by the other side.
Solution : First, in Xcode or Android Studio, start the App from the Native side, then open the IDE or terminal that compiles the Dart code, use the flutter attach
command to connect your Dart code to the running application, and then you can debug Native at the same time With Dart and code too.
5. Problems encountered on the QT desktop
5.1 Reuse of mobile interface
As mentioned earlier, our data management solution can decouple each page, and the page as a whole can be reused by other components. The desktop side uses this design pattern. It only needs to add connectors to each page on the mobile side to connect the mobile side. The view is integrated into a desktop main page, and the corresponding logic layer only needs to be partially adapted according to the characteristics of the desktop, such as calling different APIs, and supporting the right-click behavior on the desktop.
Page and Component in the figure are the basic logic and UI units provided in fish-redux. They can be combined with each other arbitrarily. They meet the requirements of QTalk multiplexing UI and logic, and are also an important basis for selection.
//各子页面适配器代码
SessionListComponent.component.dart
SessionListState
{
....
}
SessionListConnector
{
//被this的属性改变之前调用,这个组件的state来自上层组件的state的属性
get
{
return HomePCPage.scState
}
//自身属性发生改变以后调用,同步上层组件的state
set
{
HomePCPage.scState = this.state;
}
}
//桌面端主页合成代码
HomePCPage.page.dart
HomePCPage
{
....
dependencies:
//重载了+号用于增加子组件属性,返回一个带有connector的组件给上层page使用
slot:SessionListConnector() + SessionListComponent(),
}
5.2 Multiple Windows
There are many native platform-related capabilities on the PC side that Flutter-desktop does not yet have, such as multiple windows, screen recording, web usage, drag-and-drop file sharing, and menubar configuration.
Solution : Introduce the NativeShell framework, use the multi-engine method to solve the multi-window problem encountered on the PC side, change the project structure, add a rust class to manage the window before dart starts the main function, and call each platform system library in rust to Write various languages (c++, c# oc, etc.) into system api and unify them into rust-type files to reduce platform differences.
There are also many problems in adapting to NativeShell. Here are two examples:
Package script empty security error
cargo is a rust package manager, and NativeShell uses cargo to package the desktop. NativeShell does not allow libraries that do not adapt to null safety to be added to the project by default in the packaging script. We reorganized the packaging script and added the non-null judgment when compiling in Flutter. Finally, the Mac and Windows packages were successfully typed in the rust environment.
Mac client packaging issues
In the NativeShell packaging process, each Window will generate a sub-project. The shell project directly references the sub-project directory. The final package will contain a large number of intermediate products, resulting in a particularly large package size. We modified this process and only generated sub-projects. The dll and framework are added to the final product, and the normal size package is typed. We also communicated with the author, put forward a PR, and finally these codes and suggestions were merged into the producer packaging tool.
5.3 Multiple windows cause the main isolate command to queue
Before explaining this problem, let's first understand the principle of Flutter's event loop: In a Dart application, there is an event loop and two queues: event queue and microtask queue.
- event queue : Contains all external events: I/O, mouse clicks, drawing, timers, messages in Dart isolate, etc.
- microtask queue : Event handling code sometimes needs to do some tasks after the current event and before the next event.
In general, the event queue contains events from Dart and the system. Currently, the microtask queue only contains events from Dart.
When main() exits, the event loop starts working. The first is to execute all microtasks, which are actually a FIFO queue. Next, it will fetch and process the events in the first event queue. Then, start the execution loop: execute all microtasks, then execute the next event in the event queue. Once both queues are empty, that is to say there are no events, it may be handled by the host (such as the browser).
If the event loop is executing events in the microtask queue, then event processing in the event queue will be stopped, which means that image drawing, handling mouse clicks, handling I/O, etc. events will not be executed, although you can know in advance The order in which tasks are executed, however, you have no way of knowing when the event loop dequeues tasks 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 enqueued at a time you specify. However, the event preceding it has not been processed and it cannot be processed.
Most of the business logic on the PC and the mobile terminal can be reused, but there are still a few differences in the rendering process. The most common situation is that multiple sub-windows send messages to the main window at the same time. These messages will be added to the event queue in the main isolate. If the amount is too large, there will be too many events in the main isolate event queue, which will easily cause the page where the main isolate is located to be stuck.
In response to the above situation, we have added a distribution layer to solve this problem. After processing the data, the original logic controls will send a notification to the distribution layer. The distribution layer will count the number of operation requests to the main isolate from the previous rendering frame. If the threshold is exceeded, it will be added to the command queue first, and the request will be sent after waiting for the next rendering frame. If the command accumulates in the queue for too long, it will suspend accepting the queue request, and send a failure notification to the sub-isolate. The sub-isolate can choose to resend the message.
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。