Concurrency의 장점을 알아보고 사용해보자.
앱을 만들 때, 부드럽고 빠른 앱을 만들어 보자.
앱을 실행하다 보면 가끔 jank(frame이 끊김)가 생긴다.
Jank는 수행되는 코드가 UI업데이트를 막기 때문에 생긴다.
그렇다면 Dart에서 무거운 작업을 하거나, UI의 jank 없이 앱을 구동하려면 어떻게 해야할까 ?
Concurrency를 이용하자.
Concurrency는 코드를 백그라운드에서 동작하는 정말 좋은 기술이다.
앱이 빠르게 반응하고, 애니메이션 동작을 부드럽게 한다.
isolate를 이용하여 무거운 작업을 백그라운드에서 수행하는 방법을 알아보자.
여러개의 스레드를 동시에 수행한다.
Isolate에 관한 더 자세한 내용은 여길 참고 : Concurrency in Dart
작업단위는 이벤트 큐에 들어가고, isolate가 하나씩 처리한다.
부드러운 화면을 위해 이벤트큐에서 repaint 요청을 1초에 60번 한다.
이벤트 루프에서 이를 제시간에 처리하는 것이 중요하다.
그렇지 못하면 UI에서 stutter(단위시간 동안 생긴 jank)가 생긴다.
main thread를 방해하지 않고 Dart 코드를 수행
Worker thread 사용법
Core libraries / system APIs (파일 불러오기)
await와 asynchronously 사용
await File(path).readAsBytes()
짧은 수명의 백그라운드 작업 (이미지 필터 적용)
compute() 사용
await compute(_applySepia, _image)
긴 수명의 백그라운드 작업 (게임엔진)
isolate 직접 호출 + 메시지 보내기
await Isolate.spawn(_entry, _receivePort.sendPort)
_sendPort!.send('makeMove')
시간이 더 있거나 이해가 더 필요하다면 아래 예제까지 봐보자.
void _loadImage(String? path){
if (path == null) return;
setState((){
_showProgress = true;
}
final image = File(path).readAsBytesSync();
setState(() {
_showProgress = false;
});
}
이미지를 읽기 전 progress를 보여주려 한다. readAsByteSync() 메서드는 동기적으로 동작하고 모든 byte를 읽을 때 까지 화면을 멈추는데 이 때, 몇 프레임 정도 소모된다. 이벤트 루프는 이미지로딩이 끝날때까지 repaint이벤트를 처리할 수 없으므로 화면이 멈추게 된다.
이를 비동기적으로 처리하면, 이미지 로딩을 분리된 VM 스레드에서 처리할 수 있게된다.
분리된 스레드가 이미지 로딩 작업을 하는 동안, 메인 isloate는 repaint 이벤트를 처리할 수 있고, progress를 보여줄 수 있다.
VM 스레드가 일을 다 끝내면, 이벤트 큐에 이미지로딩이 끝났다는 이벤트를 추가하고, 메인 isolate는 프로그레스를 끝낸다.
아래와 같이 코드를 변경하면 앱이 부드럽게 동작한다.
void _loadImage(String? path) async{
if (path == null) return;
setState((){
_showProgress = true;
}
final image = await File(path).readAsBytesSync();
setState(() {
_showProgress = false;
});
}
불러온 이미지에 sepia 필터를 적용해보자.
void _applySepia(){
setState((){
_showProgress = true;
});
final image = _applySepiaFilter(_image!);
setState((){
_image = image;
_showProgress = false;
});
}
역시 메인 isolate에서 필터 처리 할 경우, 작업이 끝날 때 까지 화면이 멈춘다.
_applySepiaFilter() 메서드가 수행 중엔 다른 이벤트 루프를 수행하지 못하기 때문이다.
void _applySepia() async{
setState((){
_showProgress = true;
});
await Future.delayed(seconds:1);
final image = _applySepiaFilter(_image!);
setState((){
_image = image;
_showProgress = false;
});
}
처음엔 Future.delayed() 를 사용해 다른 이벤트 루프가 수행되어 progress가 나타나지만 이내 _applySepiaFilter()가 main isolate를 몇 프레임동안 수행되기 때문에 이내 화면이 멈춰보인다.
이 때, 새로운 isolate를 생성하고 여기서 작업을 수행하면 main isolate는 멈춤없이 화면을 그릴 수 있게 된다. 새로운 isolate는 compute 메소드로 사용할 수 있다.
이 방법은 간단한 이미지 처리나, JSON blob 을 처리와 같은 짧은 수명의 작업을 할 때 유용하다.
void _applySepia() async{
setState((){
_showProgress = true;
});
final image = await compute(_applySepiaFilter, _image!);
setState((){
_image = image;
_showProgress = false;
});
}
그렇다면 게임 같이 긴 수명의 작업을 할 땐 어떻게 해야할까?
이번 예제에선 Dash AI가 사용자의 반응에 맞춰 말을 놓는 작업을 연산할 때 움직임이 멈춘다. 게임엔진은 항상 사용자의 움직임을 추적하고 반응해야 하므로, 이를 해결하려면 Dash가 움직이는 게임엔진을 main isolate에서 새로운 isolate로 옮겨야 한다.
main isolate
class ConcurrentGameEngine implements GameEngine {
final ReceivePort _receivePort = ReceivePort();
late final StreamQueue _receiveQueue StreamQueue(_receivePort);
Future<UiState> start() async{
_isolate = await Isolate.spawn(_entryPoint, _receivePort.sendPort);
_sendPort = await _receiveQueue.next;
_sendPort.send('start');
return await receiveQueue.next;
}
Future<UiState> reportMove(int row, int col) async {
_sendPort!.send([row, col]);
return await _receiveQueue.next;
}
Future<UiState> makeMove() async{
_sendPort!.send('makeMove');
return await _receiveAQeue.next;
}
worker isolate
void _isolateEntryPoint(SendPort sendPort){
final receivePort = ReceivePort();
sendPort.send(receivePort.sendPort);
final engine = GameEngine();
receivePort.listen((Object? message) async {
if(message == 'start'){
sendPort.send(await engine.start());
} else if (message == 'makeMove') {
sendPort.send(await engine.makeMove());
} else if (message is List<int> && message.length == 2) {
sendPort.send(await engine.reportMove(message[0], message[1]));
}
});
}
웹에선 isolate 메서드가 지원 안된다.
Concurrency in flutter
Concurrency vs Parallelism
Concurrency in Dart
jank, stutter - PerfDog | Full Mobile Platform Performance Test & Analysis Tool