본문 바로가기

일::개발

Flutter GetX: 하나의 Controller를 사용하는 페이지 간 이동할 때의 문제 (1)

이 글은 GetX를 이용할 때 다음과 같은 상황에서 발생하는 문제를 해결하기 위한 것이다.

1. MainPage에서 사용자가 선택하면 ArticlePage로 이동한다.

2. ArticlePage는 전달되는 데이터에 따라 다른 콘텐츠를 표시한다.

3. M(MainPage) -> A(ArticlePage) -> M(MainPage) 의 경우에 각각 페이지의 Controller는 Page 이동에 따라 생성/ 삭제된다.

4. M -> A -> A(다른 콘텐츠)인 경우에 A의 Controller가 정상적으로 생성/ 삭제되지 않는다.

 

먼저 1, 2까지 간단하게 구현한 샘플 코드를 보자.

 

import 'package:flutter/material.dart';
import 'package:get/get.dart';

void main() {
  runApp(GetMaterialApp(home: const HomePage(), getPages: [
    GetPage(
      name: '/ArticlePage/:id', // id를 파라미터로 가지고 이동한다.
      page: () => const ArticlePage(),
    ),
  ]));
}

class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(),
        body: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
          TextButton(
            child: const Text('Article 1'),
            onPressed: () {
              Get.toNamed('/ArticlePage/1');
            },
          ),
          TextButton(
            child: const Text('Article 2'),
            onPressed: () {
              Get.toNamed('/ArticlePage/2');
            },
          )
        ]));
  }
}

class ArticlePage extends StatelessWidget {
  const ArticlePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    var id = Get.parameters['id'] ?? '';
    return Scaffold(
        appBar: AppBar(),
        body: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
          Text('This is ArticlePage $id'),
        ]));
  }
}

편의상 하나의 파일에 다 넣었지만, 내용은 간단하다.

ArticlePage는 각각 1, 2 두 개의 콘텐츠를 표시하고, MainPage에서 각각의 페이지로 이동할 수 있다.

여기까지는 추가 설명도 필요 없을 것이다.

 

3번까지 가보기 위해 ArticlePage에 Controller를 붙여본다. 여기서부터는 바뀐 부분만 표시한다.

class ArticlePage extends StatelessWidget {
  const ArticlePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    Get.put(ArticlePageController());		// put Controller
    var id = Get.parameters['id'] ?? '';
    return Scaffold(
        appBar: AppBar(),
        body: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
          Text('This is ArticlePage $id'),
        ]));
  }
}

class ArticlePageController extends GetxController {
  @override
  void onInit() {
    super.onInit();
    print('### ArticlePageController onInit()');
  }

  @override
  void onClose() {
    print('### ArticlePageController onClose()');
    super.onClose();
  }
}

아무런 역할을 하지 않고, 각각 onInit(), onClose()에서 로그만 찍는 Controller를 만들었다.

M -> A1(/ArticlePage/1) -> M -> A2(/ArticlePage/2) 로 이동할 때 찍히는 로그는 다음과 같다.

 

[GETX] Instance "GetMaterialController" has been created
[GETX] Instance "GetMaterialController" has been initialized
D/EGL_emulation( 9610): app_time_stats: avg=52049.47ms min=52049.47ms max=52049.47ms count=1
D/EGL_emulation( 9610): app_time_stats: avg=2541.41ms min=2541.41ms max=2541.41ms count=1
[GETX] GOING TO ROUTE /ArticlePage/1
[GETX] Instance "ArticlePageController" has been created
I/flutter ( 9610): ### ArticlePageController onInit()
[GETX] Instance "ArticlePageController" has been initialized
D/EGL_emulation( 9610): app_time_stats: avg=114.98ms min=7.92ms max=2353.46ms count=24
[GETX] CLOSE TO ROUTE /ArticlePage/1
I/flutter ( 9610): ### ArticlePageController onClose()
[GETX] "ArticlePageController" onDelete() called
[GETX] "ArticlePageController" deleted from memory
D/EGL_emulation( 9610): app_time_stats: avg=85.93ms min=9.87ms max=1539.14ms count=22
[GETX] GOING TO ROUTE /ArticlePage/2
I/flutter ( 9610): ### ArticlePageController onInit()
[GETX] Instance "ArticlePageController" has been created
[GETX] Instance "ArticlePageController" has been initialized
D/EGL_emulation( 9610): app_time_stats: avg=58.54ms min=9.64ms max=1325.26ms count=31
[GETX] CLOSE TO ROUTE /ArticlePage/2
I/flutter ( 9610): ### ArticlePageController onClose()

M -> A1 이동할 때 ArticlePageController.onInit() 가 호출되고, A1 -> M 으로 Get.back()으로 돌아갈 때 ArticlePageController가 삭제된다. 당연히 다시 M -> A2 -> M 이동할 때 ArticlePageController는 생성되었다 삭제된다.

사실 GETX에서 기본적으로 다 정보를 찍어주기 때문에 로그를 프린트할 필요도 없다. 아직은. 상상한대로 동작한다.

 

그렇다면 M -> A1 -> A2 -> A1 와 같이 이동해야 할 때는 어떻게 해야 할까?

동작 시나리오에 따라 다르지만, 히스토리를 보존해야 할 필요가 없는 경우에는 Get.offNamed(A2), Get.offNamed(A1) 하면 될 것 같다.

 

해보자.

class ArticlePage extends StatelessWidget {
  const ArticlePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    ArticlePageController controller = Get.put(ArticlePageController());

    return Scaffold(
        appBar: AppBar(),
        body: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
          Text('This is ArticlePage ${controller.articleId}'),
          TextButton(
              onPressed: () =>
                  Get.offNamed('/ArticlePage/${controller.targetId}'),
              child: Text('Move To ArticlePage ${controller.targetId}'))
        ]));
  }
}

class ArticlePageController extends GetxController {
  late String articleId;
  late int targetId;

  @override
  void onInit() {
    articleId = Get.parameters['id'] ?? '';
    targetId = 3 - int.parse(articleId);
    super.onInit();
    print('### ArticlePageController $articleId onInit()');
  }

  @override
  void onClose() {
    print('### ArticlePageController $articleId onClose()');
    super.onClose();
  }
}

 

간단하게 잘 된다.

로그도 보자

Restarted application in 607ms.
[GETX] Instance "GetMaterialController" has been created
[GETX] Instance "GetMaterialController" has been initialized
[GETX] GOING TO ROUTE /ArticlePage/1
I/flutter ( 9610): ### ArticlePageController 1 onInit()
[GETX] Instance "ArticlePageController" has been created
[GETX] Instance "ArticlePageController" has been initialized
[GETX] REPLACE ROUTE /ArticlePage/1
[GETX] NEW ROUTE /ArticlePage/2
I/flutter ( 9610): ### ArticlePageController 2 onInit()
[GETX] Instance "ArticlePageController" has been created
[GETX] Instance "ArticlePageController" has been initialized
I/flutter ( 9610): ### ArticlePageController 2 onClose()
[GETX] "ArticlePageController" onDelete() called
[GETX] "ArticlePageController" deleted from memory
[GETX] REPLACE ROUTE /ArticlePage/2
[GETX] NEW ROUTE /ArticlePage/1
I/flutter ( 9610): ### ArticlePageController 1 onInit()
[GETX] Instance "ArticlePageController" has been created
[GETX] Instance "ArticlePageController" has been initialized
I/flutter ( 9610): ### ArticlePageController 1 onClose()
[GETX] "ArticlePageController" onDelete() called
[GETX] "ArticlePageController" deleted from memory

onInit(), onClose 다 잘 불렸.... 나?

뭔가 이상하다.

파란색으로 표시한 부분이 A1에서 A2로 이동하는 부분인데, 뭔가 이상한 것을 찾았는가? 그렇다. Controller1 Close -> Controller2 Init 이 아니라 Controller2 Init -> Controller2 Close 다.

Get.offNamed() 가 호출하는 pushReplaceNamed()의 설명을 보면 "Replace the current route of the navigator by pushing the route named [routeName] and then disposing the previous route once the new route has finished animating in." 라고 되어 있다. 먼저 푸시하고 기존 라우트를 삭제하는 순서로 동작하는 것이다.

OK. 그렇다면 init 먼저 하고 close 하는 것은 이해할 수 있다.

하지만 그렇다면 Controller2 Init -> Controller1 Close 순서로 동작해야 하지 않는가?

 

그 이후에도 마찬가지로 이동 후에 기존 페이지의 Controlelr를 삭제하는 것이 아니라 새로 생성한 페이지의 Controller를 삭제하는 것으로 보인다.

조금 더 확실하게 확인해보자.

class ArticlePage extends StatelessWidget {
  const ArticlePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    ArticlePageController controller = Get.put(ArticlePageController());

    return Scaffold(
        appBar: AppBar(),
        body: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
          Text('This is ArticlePage ${controller.articleId}'),
          Obx(() => Text(
              'This Page is ' + (controller.isAlive.value ? 'alive' : 'dead'))),
          TextButton(
              onPressed: () =>
                  Get.offNamed('/ArticlePage/${controller.targetId}'),
              child: Text('Move To ArticlePage ${controller.targetId}')),
        ]));
  }
}

class ArticlePageController extends GetxController {
  late String articleId;
  late int targetId;
  final isAlive = false.obs;

  @override
  void onInit() {
    articleId = Get.parameters['id'] ?? '';
    targetId = 3 - int.parse(articleId);
    isAlive.value = true;
    super.onInit();
    print('### ArticlePageController $articleId onInit()');
  }

  @override
  void onClose() {
    isAlive.value = false;
    print('### ArticlePageController $articleId onClose()');
    super.onClose();
  }
}

Controller가 초기화되고 삭제되기 이전임을 나타내는 flag를 표시해보면 아래와 같이 A1 -> A2 이동할 때 A1 Controller의 onClose()가 아니라 A2 Controller의 onClose()가 호출되었음을 확인할 수 있다.

MainPage에서 ArticlePage1 이동했을 때
ArticlePage1 에서 ArticlePage2로 이동했을 때

샘플 코드는 간단해서 큰 문제가 없어 보이지만, ArticlePageController 에서 ScrollController 같은 다른 Controller를 사용하고,  onClose()에서 dispose() 하는 경우에 새로 이동한 페이지에서 사용해야 하는 controller가 시작하자 마자 dispose() 되어버리는 일이 발생한다.

 

여기까지는 M -> A1 -> A2 를 Get.offNamed()로 이동할 때 발생하는 문제를 확인하는 과정이었고, 해결은 다음 포스트에서!