[Flutter]Isolate를 이용한 백그라운드 프로세스

한상욱·2024년 9월 4일
0

Flutter

목록 보기
22/26
post-thumbnail

들어가며

이 글을 읽기전에 먼저 하단의 링크의 글을 읽는 것이 이해에 큰 도움이 됩니다.
[Dart]동시성과 Isolate

Flutter는 모든 작업을 main isolate에서 처리합니다. 단일 스레드로 작업을 처리하지만, 대부분의 경우 굉장히 빠르게 처리할 수 있기 때문에 사용자는 단일 스레드로 인한 큰 불편함을 격지 못합니다. 하지만, 큰 사이즈의 JSON 데이터를 파싱하는 것과 같이 많은 비용이 발생하는 작업을 진행한다면 경우에 따라서 사용자가 불편을 겪을 수 있습니다.

예를 들어, 사용자의 디바이스가 오래되어서 굉장히 처리속도가 느리다고 하겠습니다. 사용자가 앱을 실행시킨 후 서버로부터 데이터를 불러와 어떤 이미지 목록들을 본다고 했을 때, 느린 처리속도를 가진 디바이스는 처리 중 큰 JSON 데이터를 파싱해야 하고, 이 경우 앱이 버벅거리거나 잠시 정지될 수 있습니다. 이 현상을 JANK현상이라고 합니다.

이러한 JANK 현상을 방지하기 위해서 main isolate가 아닌 다른 isolate에서 JSON 파싱을 처리한다면 JANK현상을 최소화 할 수 있을 것입니다. 공식문서의 예제를 따라하며 이해해봅시다. 그리고 REST 통신을 위하여 http 라이브러리를 이용할 것입니다.

Photo Model 생성

우리는 임의의 서버에서 사진 정보를 가져올 것입니다. 그렇기에 사진 정보를 Photo라는 모델을 생성하여 객체로 변환시킬 것입니다.

class Photo {
  final int albumId;
  final int id;
  final String title;
  final String url;
  final String thumbnailUrl;

  const Photo({
    required this.albumId,
    required this.id,
    required this.title,
    required this.url,
    required this.thumbnailUrl,
  });

  factory Photo.fromJson(Map<String, dynamic> json) {
    return Photo(
      albumId: json['albumId'] as int,
      id: json['id'] as int,
      title: json['title'] as String,
      url: json['url'] as String,
      thumbnailUrl: json['thumbnailUrl'] as String,
    );
  }
}

엔드포인트 작성

http 라이브러리를 이용한 간단한 GET 메소드를 작성할 것입니다.

Future<List<Photo>> fetchPhotos(http.Client client) async {
  final response = await client
      .get(Uri.parse('https://jsonplaceholder.typicode.com/photos'));

  final parsed =
      (jsonDecode(response.body) as List).cast<Map<String, dynamic>>();

  return parsed.map<Photo>((json) => Photo.fromJson(json)).toList();
}

이 메소드를 이용하여 간단한 엔드포인트가 하나 작성되었습니다. 이 엔드포인트는 데이터를 불러와 파싱을 처리하고, Photo 객체 배열로 반환됩니다.

UI 작성

UI에서는 불러온 데이터를 GridView를 통해 보여줄 것입니다.

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  late Future<List<Photo>> futurePhotos;

  
  void initState() {
    super.initState();
    futurePhotos = fetchPhotos(http.Client());
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: FutureBuilder<List<Photo>>(
        future: futurePhotos,
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return const Center(
              child: Text('An error has occurred!'),
            );
          } else if (snapshot.hasData) {
            return PhotosList(photos: snapshot.data!);
          } else {
            return const Center(
              child: CircularProgressIndicator(),
            );
          }
        },
      ),
    );
  }
}

class PhotosList extends StatelessWidget {
  const PhotosList({super.key, required this.photos});

  final List<Photo> photos;

  
  Widget build(BuildContext context) {
    return GridView.builder(
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
      ),
      itemCount: photos.length,
      itemBuilder: (context, index) {
        return Image.network(photos[index].thumbnailUrl);
      },
    );
  }
}

결과적으로 아래와 같이 동작합니다.

제 시뮬레이터에서는 조금 버벅거려도 정상적으로 작동하지만, 조금 더 처리가 오래걸리는 사용자의 디바이스에서 실행하는 경우 Jank현상이 발생합니다.

백그라운드에서 JSON 파싱처리

이러한 문제를 해결하기 위해서 다른 Isolate를 이용하여 JSON 파싱을 처리할 것입니다.

우선, 기존의 엔드포인트에서 파싱을 처리하는 부분을 분리해주겠습니다.

List<Photo> parsePhotos(String responseBody) {
  final parsed =
      (jsonDecode(responseBody) as List).cast<Map<String, dynamic>>();

  return parsed.map<Photo>((json) => Photo.fromJson(json)).toList();
}

Future<List<Photo>> fetchPhotos(http.Client client) async {
  final response = await client
      .get(Uri.parse('https://jsonplaceholder.typicode.com/photos'));
  return parsePhotos(response.body);
}

여기서 파싱되는 부분은 백그라운드에서 다른 Isolate로 넘겨주어 처리할 것인데, 이를 가능하게 해주는 것이 바로 compute 함수입니다. compute 함수는 callback을 파라미터로 전달받는데, Isolate간 객체 또는 메시지로 데이터를 주고받기 때문에 전달 가능한 객체가 반환되어야 합니다.

이제, compute 함수를 통해 파싱을 백그라운드에서 처리하도록 하겠습니다.

Future<List<Photo>> fetchPhotos(http.Client client) async {
  final response = await client
      .get(Uri.parse('https://jsonplaceholder.typicode.com/photos'));
  return compute(parsePhotos, response.body);
}

List<Photo> parsePhotos(String responseBody) {
  final parsed =
      (jsonDecode(responseBody) as List).cast<Map<String, dynamic>>();

  return parsed.map<Photo>((json) => Photo.fromJson(json)).toList();
}

이렇게 JSON 파싱을 백그라운드에서 처리하도록 변경되었습니다.

사실, 두 작업의 차이는 별로 체감상 느껴지진 않습니다만, 시뮬레이터보다도 더 느린 디바이스에서 이러한 차이는 사용자에게 더 좋은 경험을 줄 수 있겠죠!

참고로, 무조건적으로 백그라운드에서 파싱을 처리하는 것은 오히려 프레임을 건너뛰게 되는 현상이 발생할 수 있으니 몇 ms 이상 소요되는 작업에 적용하는 것이 좋다고 공식문서에서 권유하고 있습니다.

profile
자기주도적, 지속 성장하는 모바일앱 개발자가 되기 위해

0개의 댓글