티스토리 뷰

네, 맞습니다.
오늘은 제가 Flutter 공식 저장소에 제 코드 한 줄을 조심스레, 그러나 당당하게(!) 심고 온 이야기입니다.
야릇하다고요? 지금 제 마음이 그렇습니다.
장난은 그만하고 이제 제가 왜 플러터 공식 SDK에 기여하게 되었는지에 관해서 자세히 이야기를 해드리도록 하겠습니다!
어떤 것을 기여한 것인가?
그래서 도대체 무엇을 기여한 것이길래 이렇게 마구 비틱질을 해대냐고 궁금해하실 분들이 많을 거라 생각됩니다.
일단, 기존 안드로이드 12, 즉 네이티브 환경에서 구현되는 기본 오버스크롤 효과와, 플러터에서 구현된 Stretching Overscroll 효과는 시각적인 표현 방식이 달랐습니다.
Android Native 12 구현
별도의 OpenGL 쉐이더를 활용해 콘텐츠가 당겨질 때 꽤나 균등하게 늘어나고 줄어듭니다. 덕분에 화면이 극단적이지 않고 자연스럽게 늘어나는 느낌이 납니다.
Flutter에서의 기존 구현
기존 플러터의 Stretching 오버스크롤 효과는 매우 포괄적으로 사용되는 단순 스케일 변환(Trnasform 위젯)을 적용했는데, 이 과정에서 콘텐츠가 균등하게가 아니라 특정 방향으로 특히 극단적으로 늘어나는 문제가 있었습니다. 결과적으로 네이티브와 비교했을 때 확연히 다른 시각적 차이가 발생했습니다.
제가 이번에 기여한 건, 이 효과를 새로운 플러터의 기능인 프래그먼트 셰이더(Fragment Shader)로 다시 구현하면서, 안드로이드 네이티브와 동일한 시각적 Stretching 동작을 재현한 것입니다~!
즉, 기존에는 늘어남이 한쪽으로 치우쳤다면, 이제는 부드럽고 균형 있게 콘텐츠가 당겨졌다 되돌아가는 효과를 구현한 거죠. 셰이더를 통해서 별도의 성능 저하 없이 말이죠.

그러면 어떻게 한건데?
당연하게도 저도 사람인지라 수 많은 시행착오를 겪었습니다.
단순히 안드로이드 네이티브 환경에서 쓰이는 쉐이더를 그대로 가져다 쓰는 게 아니라, 그걸 플러터의 렌더링 파이프라인에 맞춰 재구현해야 했거든요.
여기서 주요 문제는 두 가지였습니다.
첫번째, 기존 안드로이드의 오픈 소스를 분석하여 핵심 원리를 숙지하기.
두번째, 숙지한 것을 토대로 플러터에 프래그먼트 셰이더 형태로 옮기기.
구현하며 겪은 시행착오와 해결법
Flutter에서 커스텀 쉐이더를 구현하려고 시도하다 보면, 단순히 코드를 작성하는 것 이상의 고민이 필요합니다. 저는 먼저 기초적인 쉐이더 코드를 작성하면서 시작했습니다.
그 후, Flutter에서 제공하는 ImageFiltered 위젯을 활용해 쉐이더 효과를 적용했습니다. 이 위젯은 이미지를 필터링할 때 유용하지만, 중요한 점은 이 위젯이 Impeller 렌더러 전용이라는 것입니다. 따라서 관련 문서를 작성하거나 팀에게 공유할 때, Impeller 환경에서만 동작한다는 점을 반드시 명시해야 했습니다.
또한 렌더링 파이프라인에서 예상치 못한 문제가 발생했습니다. 쉐이더가 화면 전체를 제대로 덮지 못하고, 일부 영역에서 최적화로 인해 그려지지 않는 문제가 생긴 것이죠. 이를 해결하기 위해 거의 투명한 픽셀을 캔버스의 네 모서리에 그리는 트릭을 사용했습니다. 이렇게 하면 쉐이더가 캔버스 전체 영역을 커버하도록 강제할 수 있었습니다. (근데 신기하게도 본능인지 경험인지 그냥 이런 간단한 해결 방법이 생각나더군요...?)
아래는 제가 이와 관련하여 구현한 코드입니다.
/// A [CustomPainter] that draws nearly transparent pixels at the four corners.
///
/// This ensures the fragment shader covers the entire canvas by forcing
/// painting operations on all edges, preventing shader optimization skips.
class _StretchEffectPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final Paint paint = Paint()
..color = const Color.fromARGB(1, 0, 0, 0)
..style = PaintingStyle.fill;
canvas.drawPoints(ui.PointMode.points, <Offset>[
Offset.zero,
Offset(size.width - 1, 0),
Offset(0, size.height - 1),
Offset(size.width - 1, size.height - 1),
], paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
이 기법의 핵심 포인트는 거의 투명한 픽셀을 그려서 캔버스의 가장자리까지 “그림이 그려졌다”라고 렌더러에 알려주는 것입니다. Flutter는 최적화를 위해 사용되지 않은 픽셀 영역의 쉐이더 실행을 생략하는데, 이 방식으로 지멋대로 이를 생략하는 것을 방지할 수 있습니다.
결과적으로, 이런 작은 트릭 하나로도 꽤나 수월하게 쉐이더 구현을 할 수 있었습니다. Flutter에서 쉐이더를 다루면서, 단순히 코드를 작성하는 것 이상으로 렌더링 파이프라인과 최적화 동작까지 이해하는 것이 중요하다는 점을 다시 한번 깨달았습니다.
여기서 가장 희귀한 경험을 했던 것은 플러터 측의 FFI 버그가 있었고 그냥 제가 그걸 찾은 것이였습니다. (ㄷㄷ)
════════ Exception caught by scheduler library ═════════════════════════════════
The following ArgumentError was thrown during a scheduler callback:
Invalid argument(s): Couldn't resolve native function 'ImageFilter::equal' in 'dart:ui' : Couldn't resolve function: 'ImageFilter::equal'.
When the exception was thrown, this was the stack:
#0 Native._ffi_resolver_function.#ffiClosure0 (dart:ffi-patch/ffi_patch.dart)
#1 Native._ffi_resolver_function (dart:ffi-patch/ffi_patch.dart:1943:26)
ffi_patch.dart:1943
#2 _FragmentShaderImageFilter._equals (dart:ui/painting.dart)
#3 _FragmentShaderImageFilter.== (dart:ui/painting.dart:4492:9)
painting.dart:4492
#4 ImageFilterLayer.imageFilter= (package:flutter/src/rendering/layer.dart:2008:15)
layer.dart:2008
#5 _ImageFilterRenderObject.updateCompositedLayer (package:flutter/src/widgets/image_filter.dart:106:11)
image_filter.dart:106
#6 PaintingContext._repaintCompositedChild (package:flutter/src/rendering/object.dart:157:46)
object.dart:157
#7 PaintingContext.repaintCompositedChild (package:flutter/src/rendering/object.dart:121:5)
object.dart:121
#8 PipelineOwner.flushPaint (package:flutter/src/rendering/object.dart:1312:31)
object.dart:1312
#9 PipelineOwner.flushPaint (package:flutter/src/rendering/object.dart:1322:15)
object.dart:1322
#10 RendererBinding.drawFrame (package:flutter/src/rendering/binding.dart:631:23)
binding.dart:631
#11 WidgetsBinding.drawFrame (package:flutter/src/widgets/binding.dart:1261:13)
binding.dart:1261
#12 RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:495:5)
binding.dart:495
#13 SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1438:15)
binding.dart:1438
#14 SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1351:9)
binding.dart:1351
#15 SchedulerBinding._handleDrawFrame (package:flutter/src/scheduler/binding.dart:1204:5)
binding.dart:1204
#16 _invoke (dart:ui/hooks.dart:331:13)
hooks.dart:331
#17 PlatformDispatcher._drawFrame (dart:ui/platform_dispatcher.dart:444:5)
platform_dispatcher.dart:444
#18 _drawFrame (dart:ui/hooks.dart:303:31)
hooks.dart:303
════════════════════════════════════════════════════════════════════════════════
테스트 용도로 제 코드가 적용된 플러터 SDK로 개발을 진행하고 있던 와중, 특정 위젯 트리 구조 또는 렌더링 과정에서 위와 같은 크래쉬가 발생했습니다.

저는 ImageFilter 쪽에서 인터페이스 불일치 문제가 발생하는 것을 어렴풋이 알았고, 이와 관련하여 자세히 분석하고 해당 사실을 플러터 개발자들과 공유하였습니다. (매우 뿌듯)


그리고 결국 리뷰어, 특히 주로 쉐이더를 담당하시는 리뷰어 분도 FFI 문제가 맞는 거 같다는 공식적인 답변을 받았습니다.
(이건 엔진 쪽 FFI 문제라서 추후 빠르게 해결된 것 같았습니다. 중간에 문제가 갑자기 완전히 해결됐었던 것을 보면요.)
그리고 회귀 테스트도 매우 중요한 단계였습니다. 사실 구현 자체보다 테스트 과정이 더 오래 걸렸습니다. 이유는 간단합니다. 핵심 효과를 수정하는 작업이었기 때문에, 수만 가지에 달하는 테스트를 모두 통과해야 했기 때문입니다.
특히, 기존 테스트 케이스에서 예외 상황이 발생할 수 있는 부분까지 고려하여 테스트 코드에 대한 추가 작성과 예외 처리가 필요했습니다. 이런 과정 덕분에 예상치 못한 버그를 미리 방지할 수 있었습니다.
모든 테스트가 성공적으로 완료된 후, PR을 제출하고 코드 리뷰를 진행했습니다. 리뷰 과정에서는 다음과 같은 작업도 수행되었습니다.
- 추가적인 문서화
- 사소하지만 중요한 여러 문제점 수정
- 코드 스타일 및 주석 보완
결국, 단순히 기능을 구현하는 것뿐만 아니라, 테스트와 리뷰, 문서화까지 포함한 전체 프로세스를 거쳐야 안정적인 쉐이더 구현이 가능하다는 점을 다시 한번 깨닫게 되었습니다.
철저한 코드 리뷰와 성장의 경험
PR을 제출한 후 진행된 코드 리뷰 과정은 솔직히 말해서 꽤 엄격했습니다. 심지어 리뷰어 한 분은 쉐이더 코드의 if 문 하나에도 최적화 제안을 해주셨습니다. 처음에는 조금 당황스럽기도 했지만, 이런 철저함 덕분에 코드 품질이 눈에 띄게 향상되었습니다.
코드 리뷰 과정에서 메인 리뷰어는 StretchOverscrollEffect를 private으로 만들고, StretchingOverscrollIndicator 코드에 통합하자는 제안을 했습니다. 이유는 간단하게 문서화를 최소화할 수 있고 임펠라 전용 효과이므로 캡슐화되어 있지 않다는 점이였습니다.
초반에는 이 방향이 타당해 보였지만, 솔직히 저는 커스터마이징을 매우 자주하고 심지어 기존 플러터 효과도 재구현을 자주 하기에 많이 아쉬웠습니다. 솔직히 리뷰어가 제안한 방법은 기존 커스터마이징에 매우 친화적인 플러터 방향성과는 맞지 않아 보였거든요. 그래서 저는 단순히 리뷰 의견을 수용하지 않고 한가지 대안을 리뷰어에게 제안했습니다.

위 사진처럼 저는 "캡슐화하여 랩퍼 위젯을 통해 분기 처리하는 것이 어떻겠냐"라는 제안을 하였습니다. 해당 제안을 한 이유는 두 리뷰어가 지적한 것을 전부 해결하거나 대부분을 하는 좋은 방식이라고 생각했기 때문입니다.
결과적으로 다행히도 리뷰어도 해당 제안을 받아들였고, 해당 위젯은 보다 유연하고 재사용 가능한 형태로 개선되었습니다~!
이 과정을 통해 단순히 리뷰를 수동적으로 따르는 것이 아니라 설계와 코드 품질에 직접적으로 기여하는 개발자의 역할을 경험할 수 있었습니다. (대상이 구글 플러터 개발자이든 간에)
그리고 결국엔 3개월차되고선 제가 올린 PR은 총 3명의 리뷰어에게 리뷰 및 승인(LGTM)을 받고 메인 브랜치에 최종적으로 야릇하게(?) 병합되었습니다.
뭐 이 외에도 여러 시행착오와 리뷰 과정이 있었지만, 글에서 모두 다루기에는 내용이 방대하여 여기서는 간단히 언급만 하고 넘어가겠습니다. 자세한 내용은 아래 깃허브 PR 링크에서 확인하실 수 있습니다.
오늘도 부족한 제 글을 읽어주셔서 감사합니다! (꾸벅)
flutter/flutter PR #169196
Implements the Android native stretch effect as a fragment shader (Impeller-only). by MTtankkeo · Pull Request #169293 · flutt
Sorry, Closing PR #169196 and reopening this in a new PR for clarity and a cleaner commit history. Fixes #82906 In the existing Flutter implementation, the Android stretch overscroll effect is ach...
github.com
- Total
- Today
- Yesterday
- 디자인 패턴
- 안드로이드
- flutter contribution
- 객체 지향
- 플러터
- Factory Method
- 조건부 타입
- 타입스크립트
- TypeScript
- 최적화
- Flutter
- pagetransitionsbuilder
- webpack
- svg
- github
- Reflow
- omit
- web
- JavaScript
- keystore_signature
- 팩토리 메서드
- 객체지향
- 안드로이드 개발
- StretchEffect
- compose_appbar
- 깃허브
- android
- jetpack compose
- pageroute
- 플러터 기여
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |