본문 바로가기
프로그래밍/Flutter

[Flutter] Provider 성능 최적화

by YuminK 2022. 6. 12.

Flutter의 provider라는 패키지를 사용하여 프로그램을 개발하고 있었는데, 어플 실행시 CPU점유율이 너무 높고 발열이 심한 이슈가 있어서 성능 최적화 작업을 진행하게 되었습니다. 개발과정에서도 어플이 느리긴 했는데 디버그 모드 생각해서 크게 이슈라고 생각하지 않았다가 릴리즈 모드에서 성능이 너무 안 좋아서.... ㅠ

 

개선 방향성
 - 모든 화면에 대한 build함수 호출을 줄이는 방향
 - rebuild가 필요하더라도 최소한의 리소스를 사용하는 방향(캐싱)

Android Native 앱을 프로파일링 했을 때 CPU점유율이 어느 정도 되는지 비교하고 비슷한 수준으로 맞추거나 
더 빠르게 만드는 것을 목표로 하고 개선 작업을 진행했습니다.

 

테스트 내역(Native 수준으로 빨라질 수 있는가?)

더보기

이 부분에서 테스트를 해봤는데,

Flutter의 기본 MaterialButton을 20개씩 넣고 버튼을 눌러보거나 setState를 계속 호출해보고 프로파일링을 했을 때 기본적으로 CPU점유율을 30% 정도 먹었습니다. (에뮬레이터 환경)

 

Android에서 MaterialButton 20개를 넣고 버튼을 마구 누른다고 하더라도 이 정도의 점유율까지 올라가지는 않습니다. 

 

Flutter의 렌더링 엔진에 대한 차이로 인해 Android Native보다 더 빠르게 처리를 하는 것은 현실적으로 불가능한 내용으로 판단했습니다. 하지만 여러 플랫폼을 동시에 개발한다는 부분에서는 이미 생산성을 압도하니 사용하는 것이지요.

 

일단, 기존에 개발이 되어 있던 Android앱과 Flutter앱간의 성능차이를 비교했습니다. 

안드로이드
Flutter

네이티브 어플에서 기본적으로 한 5 ~ 25% 정도의 cpu 점유율이 나오는 반면에 Flutter앱에서는 기본이 30%이고 50%까지 튀는 모습을 보실 수 있습니다. 

 

특히 키보드 입력 처리할 때 CPU점유율을 엄청 먹고 있었고 이러한 부분에서 지속적인 rebuild가 일어나고 있는 것이 명확했습니다. 

 

네이티브 앱에서는 회의 내부에서 카메라 키면서 자신의 영상을 뿌리는 정도는 되어야 30~40%의 성능이 나왔습니다.

 

다음은 Provider 공식 홈페이지를 보면서 잘못 사용한 부분에 대해서 공부한 내용입니다.


Build 메소드가 호출되는 상황

build method의 경우에는 다양한 상황에서 호출이 된다.
따라서 기본적으로 build에서 http call이나 상태를 바꾸는 처리를 넣지 말라고 하는 것. 

상태가 바뀌면 build를 호출하는 재귀적인 상황이 발생할 수 있고 불필요한 초기화가 여러 번 발생한다.

The build method is designed in such a way that it should be pure/without side effects. This is because many external factors can trigger a new widget build, such as:

 

Route pop/push
Screen resize, usually due to keyboard appearance or orientation change
The parent widget recreated its child
An InheritedWidget the widget depends on (Class.of(context) pattern) change
This means that the build method should not trigger an http call or modify any state.


=> 초기에 처리해야 하는 부분들은 StatefulWidget의 initState에 넣어라.

자식들이 build가 되지 않도록 할 수 있으면 최대한 그렇게 해라. (const는 항상 붙여라)
불필요한 리빌드를 막을 수 있도록 한다.  cache처리

In these below situation build method call

After calling initState
After calling didUpdateWidget
when setState() is called.
when keyboard is open
when screen orientation changed
If Parent widget is build then child widget also rebuild
=> Consumer를 사용할 때 필요한 부분에만 builder로 감싸야 하는 이유

or 변하지 않을 위젯으로 child 속성을 최대한 활용해야 하는 이유


Provider 사용 예제

// DON'T create your object from variables that can change over time.
// In such a situation, your object would never update when the value changes.
int count;
Provider(
  create: (_) => MyModel(count), // never update
  child: ...
)

 

다음과 같이 Notifier의 기본 생성자를 사용하는 경우에는 매번 한번만 처리가 된다고 합니다.

즉, count값에 의해서 업데이트 되지 않을 것.

 

이러한 부분은 value 생성자를 사용하는 경우와 대조가 됩니다. value생성자를 사용하는 경우에는 매번 빌드를 하고 참조하는 곳에 의해 생성이 된다고 합니다.

// this is bad! 
// everytime this widget is rebuilt, it will create a new instance of `Person`
// and every new instance of Person will trigger a rebuild in _every_ widget reading this value.
ChangeNotifierProvider.value(
  value: Person(), 
  child: ...
)


// To expose a newly created object, use the default constructor of a provider. 
// Do not use the .value constructor if you want to create an object, 
Provider(
  create: (_) => MyModel(),
  child: ...
)

 

Person person = Person();

...
ChangeNotifierProvider<Person>(
  create: (_) => person; // bad!
)

다음과 같이 사용하지 말라고 합니다. person 객체가 사라진 경우에 다른 쪽에서 참조를 하고 있으면 다시 재성성이 될 가능성이 있다고 합니다. 


Provider는 기본적으로 사용되는 시점에 처리가 되는데 미리 계산되길 원하면 lazy속성을 바꿔라.

MyProvider(
  create: (_) => Something(),
  lazy: false,
)

 

정리하면...

1. notifier를 생성하는 경우에는 항상 기본 생성자를 사용한다.

(한번만 처리를 할 거니까... 갱신될 여지가 없는 경우)

2. 기존에 있는 notifier에 대한 처리를 하는 경우에는 value생성자를 사용한다.

(갱신될 여지가 있는 경우에 value 생성자를 사용하고 기존의 내용을 다시 사용하자.)


Provider 확장 함수

context.watch<T>(), which makes the widget listen to changes on T
Provider.of<T>(context)
listen: true => ChangeNotifier의 값이 변하면 인식한다
상관없는 값이 변해도 rebuild된다.

context.read<T>(), which returns T without listening to it

Provider.of(context, listen: false)

context.read는 주로 Event Handler로서 사용하며 위젯의 상태 처리를 위해 read를 사용하지 않는 것을 권장한다.

 

context.select<T, R>(R cb(T value)), which allows a widget to listen to only a small part of T.
T의 일부분만 선택하여 인식하도록 허용. Observable
상관없는 값이 변하는 경우에는 build가 되지 않는 방식


Provider 함수별 성능 차이

selector는 consumer보다 더 나은 방안을 제시(rebuild의 측면에서)
consumer의 경우에는 일반적인 상황에서 사용하기에 좋다. 
=> build 메소드의 호출로 인해 영향이 큰 경우에는 사용하지 마라. 

https://flutterbyexample.com/lesson/rebuilding-widgets-with-consumer
Consumer로 감싼 부분은 리빌드 타임마다 매번 다시 그려질 거고 수 백개의 위젯이 있는 상황에서는 사용하지 말자.

1. Consumer에서 재사용하는 부분에는 child를 사용해라. 
builder 콜백 내부에 있는 것은 항상 재빌드가 된다. 
=> 최대한 builder내부를 최소화해라. 
변하지 않을 부분을 child로 제외하여 처리한다.

2. Selector를 사용하는 경우에도 마찬가지 내가 원하는 값에 대한 정보만 인식하도록 하고 최소한 builder내부를 줄이고 변하지 않을 부분에 대해서는 child로 뺀다.

 

context.read vs context.select

DON'T call read inside build if the value is used only for events:

Widget build(BuildContext context) {
  // counter is used only for the onPressed of RaisedButton
  final counter = context.read<Counter>();

  return RaisedButton(
    onPressed: () => counter.increment(),
  );
}

While this code is not bugged in itself, this is an anti-pattern. It could easily lead to bugs in the future after refactoring the widget to use counter for other things, but forget to change read into watch.

 

context.read를 build내부에서 사용하지 말라고 한다. (안티패턴)

상단에 notifier를 받아서 처리를 하면 위젯에 대한 상태 처리를 할 때 notifier로 접근하여 계속 저 값을 사용할텐데

read를 통해 위젯의 상태 처리를 하는 것은 권장하지 않는다. (밑에서 추가적으로 나오는 내용)

If that is incompatible with your criteria, consider using Provider.of(context, listen: false).
CONSIDER calling read inside event handlers:

Widget build(BuildContext context) {
  return RaisedButton(
    onPressed: () {
      // as performant as the previous solution, but resilient to refactoring
      context.read<Counter>().increment(),
    },
  );
}

이벤트 핸들러로서 read를 사용하는 것은 괜찮다. 


DON'T use read for creating widgets with a value that never changes

Widget build(BuildContext context) {
  // using read because we only use a value that never changes.
  final model = context.read<Model>();

  return Text('${model.valueThatNeverChanges}');
}

While the idea of not rebuilding the widget if something else changes is good, this should not be done with read. Relying on read for optimisations is very brittle and dependent on an implementation detail.

 

rebuild를 하지 않는다는 측면에서 read를 사용하는 생각은 나쁘지 않다.

다만, 최적화를 read에 맡기는 것은 매우 취약하며 세부 구현에 따라서 달라질 수 있다. 

 

CONSIDER using select for filtering unwanted rebuilds

Widget build(BuildContext context) {
  // Using select to listen only to the value that used
  final valueThatNeverChanges = context.select((Model model) => model.valueThatNeverChanges);

  return Text('$valueThatNeverChanges');
}

While more verbose than read, using select is a lot safer. It does not rely on implementation details on Model, and it makes impossible to have a bug where our UI does not refresh.

 

원하지 않는 rebuild를 막고 싶다면 select를 사용해라. select를 사용하는 것이 read보다 더 안전하며 모델의 구현에 의존하지도 않는다. (최적화) UI가 변경되지 않는 이슈도 만들지 않는다.

=> widget의 visibility처리, opacity 같은 처리나 인자로 값을 넘겨줄 때 context.select를 사용하라는 의미이다.


Provider 최적화 적용

1. value 생성자를 교체하는 작업
나의 경우에는 모두 생성을 하면서 시작을 했기 때문에 value 생성자를 항상 안 쓰면 된다.

 ChangeNotifierProvider<MainNotifier>(
   create:(_) => MainNotifier(),
   child: MainScreen(),
 )

 

 
2. 다음은 Consumer같은 부분을 확장 함수를 사용하여 수정했다.

eventHandler로서 context.read를 적극적을 활용하고 notifier의 값을 인식할 필요가 있는 부분에는 모두 context.select((T notifier) => notifier.value) 형태를 사용했다.

 

context.watch의 경우에는 리스트 처리에서 bulider 내부에서 사용했다.

(처음에는 selector로 했는데 builder내부에서는 watch를 쓰라고 오류가 떴다.)

// orignal
 _dateText(notifier.strStartDate, notifier.strStartDateTime),

// new
_dateText(context.select((AddConfNotifier notifier) => notifier.strStartDate),
 context.select((AddConfNotifier notifier) => notifier.strStartDateTime)),

최대한 rebuild의 호출을 막는다. =>

context.select를 적극적으로 활용한다. 

notifier를 생성할 때는 기본 생성자를 사용한다.

 

rebuild가 호출이 되었다면 최대한 캐싱 기능을 이용해라  =>

const는 가능하면 항상 붙인다.

Consumer, Selector에서 child속성을 활용해라, 꼭 필요한 부분에서만 builder로 처리한다.

 

더 높은 최적화가 필요하다면 context.select 문법보다 Selector를 사용하는 것이 좋다. (명확한 범위지정)

Provider 최적화 전/후

에뮬레이터 환경에서 MaterialButton을 여러개 놓고 테스트를 했을 때 기본적으로 30%의 점유율이 나온다.

(실기기에서는 10% 정도 낮게 나왔다) 

MaterialButton

기본이 30% 정도 나오는 것을 감안하고 최적화 결과를 받아들인다.

 

개선 전

 

개선 후

초기 파란 + 버튼을 누르는 부분을 비교를 해보면 개선 이후에는 30% 정도가 나오는 반면 개선 전에는 40% 정도이고 50%까지 튀는 것을 확인하실 수 있습니다. 

 

비밀번호 변경에서 텍스트 입력하는 부분에서도 명확히 차이가 나는데 초기에 키보드가 올라오는 처리 이후에 20% 정도의 CPU점유율을 가지는 반면, 개선 전에서는 기본이 40% 정도로 나오고 있습니다.

 

명확하게 비교를 하기에는 큰 차이가 없는 부분도 존재하지만, 평균적으로 CPU 점유율 최댓값이 낮아졌으며 특정 이벤트 시점을 확인했을 때 최적화 작업이 이루어 진 것을 확인할 수 있었습니다. 


References

https://flutterbyexample.com/lesson/the-most-basic-example-using-provider

https://pub.dev/packages/provider 

https://pub.dev/documentation/provider/latest/provider/ReadContext/read.html

댓글