preface
A smooth user experience is a constant pursuit of every developer, and we naturally want to avoid jank in order to ensure that our app delivers a consistently high framerate rendering experience.
This article will explain why Jank can occur even with Flutter’s high rendering capabilities, and how to deal with these situations. This is the first article in a series on Flutter performance analysis that will continue to examine the rendering process and performance optimization in Flutter.
When will Jank come out?
I have seen many developers try to develop applications for Flutter after they first got their hands on it, but the performance has not been good. For example, it is possible to get a noticeable lag while a long list is loading (although this is not common). When you don’t have a clue about this situation, you might mistake the Flutter rendering for not being efficient enough, but chances are your posture is wrong. Let’s look at a little example.
In the center of the screen there is a FlutterLogo that keeps spinning. When we click the button, we start to calculate 0 + 1 +… + 1000000000. There is a very clear sense of the apparent crunch. Why does this happen?
Flutter Rendering Pipeline
Flutter is driven by THE GPU’s Vsync signal. Each signal goes through a complete pipeline (we don’t need to worry about the details of the whole process right now). The most common part of Flutter is to use dart code. Build -> Layout -> Paint generates a layer, all in one UI thread. Flutter requires a pipline to be performed via vsync 60 times per second (16.67 ms).
In Android, you cannot perform time-consuming operations in the main thread (UI thread). If you perform heavy operations, such as network requests, database operations, etc., the UI thread will freeze and trigger the ANR. So we need to put these operations on the child thread and pass the results to the main thread via handler/looper/ Message Queue. Dart is single-threaded by nature, so why can we easily do these tasks without having to open another thread?
If you’re familiar with DART, you probably know the Event loop mechanism. Asynchronous processing allows you to pause a method in the middle of execution to ensure that your synchronized method executes on time (which is why setState can only be synchronized). The entire Pipline is a synchronous task, so the asynchronous task pauses and waits for the pipline to finish so that the UI doesn’t get stuck doing time-consuming operations.
Single threading has its limitations, but when we have heavy synchronization tasks, such as parsing a large amount of JSON (which is a synchronization operation), or processing images, it is likely to take longer than one vsync. The Flutter cannot send the Layer to the GPU thread in time, causing jank to be applied.
In the example above, we calculate 0 + 1 +… +1000000000 to simulate a time-consuming JSON parsing operation, which is not suspended because it is a synchronous behavior. Our complex computation took longer than one sync, resulting in the obvious Jank.
int doSomeHeavyWork() {
int res = 0;
for (int i = 0; i <= 1000000000; i++) {
res += i;
}
return res;
}
Copy the code
How to solve
Since dart can’t solve this problem with a single thread, it’s easy to think of using multiple threads to solve this problem. In DART, its thread concept is called ISOLATE.
Different from the concept of Thread, each ISOLATE cannot share memory space, and each isolate has its own event loop. We had to pass the message through Port, process it in another ISOLATE and then pass the result back so that our UI thread had more room to process the pipeline without getting stuck. Refer to the ISOLATE API documentation for more conceptual descriptions.
Create an isolate
We can create an Isolate with isolate. spawn.
static Future<Isolate> spawn<T>(void entryPoint(T message),T message);
Copy the code
When we call isolate. spawn, it will return a Future reference to the ISOLate. spawn. You can use the ISOLATE to control the isolate you create, such as pause, resume, kill, and so on.
- EntryPoint: This is where we pass in a message of any type that we want to execute on the other isolate. EntryPoint can only be a top-level method or a static method, and the return value is void.
- Message: Creates the entry parameter to the first method called on the Isolate. It can be any value.
But before that we had to create a bridge between the two isolates.
ReceivePort / SendPort
Between the two isolates, we had to pass messages through ports. ReceivePort and SendPort are like one one-way communication phone. ReceivePort comes with a SendPort. When we created the ISOLATE, we threw the SendPort of ReceivePort to the created ISOLATE. When the new ISOLATE finished computing, the sendPort was used to send messages.
static void _methodRunAnotherIsolate(dynamic message) {
if (message is SendPort) {
message.send('Isolate Created! '); }}Copy the code
This assumes that there is a method that needs to be executed on another ISOLATE with SendPort as the entry. Note that this method can only be a top-level or static method, so we use the static modifier and make it a private method (“_”). It can only return void, so you might ask, how do we get the result?
Remember the ReceivePort we created earlier. Yes, now we need to listen on the ReceivePort to get the message sent by sendPort.
createIsolate() async {
ReceivePort receivePort = ReceivePort();
try {
// create isolate
isolate =
await Isolate.spawn(_methodRunAnotherIsolate, receivePort.sendPort);
// listen message from another isolate
receivePort.listen((dynamic message) {
print(message.toString());
});
} catch (e) {
print(e.toString());
} finally {
isolate.addOnExitListener(receivePort.sendPort,
response: "isolate has been killed"); } isolate? .kill(); }Copy the code
We created ReceivePort first and passed ReceivePort. SendPort as a message to the new Isolate. Spawn.
Then listen on the receivePort and print the received message. The important thing to note here is that we need to manually call the ISOLATE? .kill() to shut down the isolate.
Output result:
flutter: Isolate Created!
flutter: isolate has been killed
I don’t actually write isolate here, right? .kill() also destroys the ISOLATE automatically during GC.
At this point you might ask, our entryPoint allows only one entry parameter, but what if the method we want to execute requires passing in additional parameters.
Define the agreement
It’s really simple. We just define a protocol. For example, we define a SpawnMessageProtocol as message like the following.
class SpawnMessageProtocol{
final SendPort sendPort;
final String url;
SpawnMessageProtocol(this.sendPort, this.url);
}
Copy the code
The protocol contains SendPort.
More convenient Compute
We used isolate. spawn to create isolate. is there a better way? In fact, Flutter has encapsulated some practical methods for us to use multithreading more naturally. Here we first create a method that needs to run on other ISOLates.
static int _doSomething(int i) {
return i + 1;
}
Copy the code
Compute is then used to execute the method in another ISOLATE and return the result.
runComputeIsolate() async{
int i = await compute(_doSomething, 8);
print(i);
}
Copy the code
With just one line of code we were able to make _doSomething run on another ISOLATE and return the result. This approach is almost unburdensome to the consumer and is basically the same as writing asynchronous code.
At what cost
For us, multithreading is actually used as a computational resource. We can reduce the burden of UI threads by creating a new ISOLATE that calculates heavy work. But at what cost?
time
In general, when we do multithreaded computation, the entire computation takes longer than a single thread. What’s the extra time?
- Create the Isolate
- Copy Message
When we executed a piece of multi-threaded code following the above code, we went through the creation and destruction of the ISOLATE. Here is one possible way we could write code like this in parsing JSON.
static BSModel toBSModel(String json){}
parsingModelList(List<String> jsonList) async{
for(var model in jsonList){
BSModel m = awaitcompute(toBSModel, model); }}Copy the code
When parsing JSON, it’s possible to compute parsing in a new ISOLATE and then pass the values. At this point, we will find that the whole parsing becomes extremely slow. This is because we went through the process of creating and destroying isolate each time we created BSModel. This will take about 50-150ms.
In this process, we passed data through Network -> Main Isolate -> New Isolate (result) -> Main Isolate, with two more copy operations. If we download data from the ISOLATE outside the Main thread, we can parse the data directly from that thread and only need to pass back the Main ISOLATE, saving a copy operation. (Network -> New Isolate (result)-> Main Isolate)
space
The Isolate is actually quite heavy, and every time we create a new Isolate we need at least 2MB of space or more, depending on what our Isolate is used for.
OOM risk
We might use message to pass data or file. In fact, the message we pass has gone through a copy process, which may lead to OOM risk.
If we wanted to return 2GB of data, on the iPhone X (3GB OF RAM), we wouldn’t be able to do message passing.
Tips
It has been mentioned above that using isolate to perform multi-thread operation will have some extra cost. Is there any way to reduce this cost? My personal advice is to start in two directions.
- Reduced the cost of creating isolate.
- Reduce the number and size of message copies.
Use the LoadBalancer
How to reduce the cost of creating the ISOLATE. One natural idea is to create a thread pool and initialize it there. We can use it when we need it.
In fact, the Dart Team has written a very practical package for us that includes LoadBalancer.
We now add the dependency of ISOLATE in pubspec.yaml.
isolate: ^ 2.0.2
Copy the code
Then we can create a specified number of isolations using LoadBalancer.
Future<LoadBalancer> loadBalancer = LoadBalancer.create(2, IsolateRunner.spawn);
Copy the code
This code creates an ISOLATE thread pool and automatically implements load balancing.
Since Dart naturally supports top-level functions, we can create this LoadBalancer directly in the DART file. Let’s take a look at how to use the ISOLATE in LoadBalancer.
int useLoadBalancer() async {
final lb = await loadBalancer;
int res = await lb.run<int.int>(_doSomething, 1);
return res;
}
Copy the code
Future
run
(FutureOr
function(P argument)) We still need to pass in a function to run on some ISOLATE and pass in its argument. The run method will return the value of the method we executed.
The overall feel is similar to Compute, but when we use extra isolate multiple times, there is no need to create it again.
LoadBalancer also supports runMultiple, which allows a method to execute in multiple threads. See the API for details.
LoadBalancer has been tested to initialize the thread pool the first time it uses its ISOLATE.
When the application is open, even if we call loadbalancer.create in the top-level function, there is still only one Isolate.
When we called the run method, we actually created the actual ISOLATE.
Write in the last
The reason to write this article is actually two days ago when the French air big guy in the picture processing just met this problem, he finally called the original library to solve, but I still write a, to meet this problem after the students a reference scheme.
Of course, performance tuning of Flutter is much more than this. Each process of Build/layout/paint has many details that can be optimized. This will be shared in the future performance optimization series.
Recently, I have been learning the knowledge related to hybrid stack for a long time. Later, I will introduce the official hybrid access scheme to Idle fish Flutter Boost. The next article will be the first one of hybrid development, I hope I can not delay more 🤣
That’s all for this time. If you have any questions or suggestions about this article, please feel free to contact me in the comments section below or at [email protected], and I will reply as soon as possible!