From ba61d90059d5bd646c6054511d6fbc502e659ac2 Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Mon, 14 Sep 2026 15:03:26 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EA=B5=AC?= =?UTF-8?q?=EB=8F=85=20=EA=B3=B5=EC=9C=A0=20=EC=8A=A4=ED=8A=B8=EB=A6=BC=20?= =?UTF-8?q?=E2=80=94=20=EC=97=AC=EB=9F=AC=20=ED=99=94=EB=A9=B4=EC=9D=B4=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B8=20=EA=B5=AC=EB=8F=85=20=ED=95=98=EB=82=98?= =?UTF-8?q?=EB=A5=BC=20=EB=82=98=EB=88=A0=20=EC=93=B4=EB=8B=A4=20=C2=B7=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=8B=9C=ED=97=98=EC=9D=84=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EC=95=88=20=EC=97=90=EB=AE=AC=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=8F=84=EA=B5=AC=EB=A1=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/firebase/functions/package.json | 2 +- app/lib/core/data/shared_stream.dart | 75 +++++++++++++++++++++++++++ app/test/shared_stream_test.dart | 76 ++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 app/lib/core/data/shared_stream.dart create mode 100644 app/test/shared_stream_test.dart diff --git a/app/firebase/functions/package.json b/app/firebase/functions/package.json index 5d26ebb..8f5af91 100644 --- a/app/firebase/functions/package.json +++ b/app/firebase/functions/package.json @@ -12,7 +12,7 @@ "build": "tsc", "typecheck": "tsc --noEmit", "test": "vitest run", - "test:emulator": "firebase emulators:exec --config ../firebase.json --only firestore --project demo-fanuzu \"npm test\"" + "test:emulator": "../test/node_modules/.bin/firebase emulators:exec --config ../firebase.json --only firestore --project demo-fanuzu \"npm test\"" }, "dependencies": { "firebase-admin": "13.6.0", diff --git a/app/lib/core/data/shared_stream.dart b/app/lib/core/data/shared_stream.dart new file mode 100644 index 0000000..ac6cbff --- /dev/null +++ b/app/lib/core/data/shared_stream.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +/// 원본 구독 하나를 여러 화면이 나눠 쓰는 스트림. +/// +/// **왜 필요한가.** 이클립스 집계는 HOME 배너·GALAXY 배너·행성 HUD·방어 화면이 +/// 동시에 본다. 화면마다 `snapshots()` 를 열면 같은 문서에 리스너가 화면 수만큼 +/// 붙고, 화면이 겹쳐 뜨면(탭 위에 모달) 더 늘어난다. 실측에서 한 팬덤에 리스너가 +/// 최대 7 건까지 붙었다. 읽기 비용이 그만큼 늘고, 값이 도착하는 순서도 갈린다. +/// +/// 여기서는 첫 구독자가 원본을 열고 마지막 구독자가 떠날 때 닫는다. 그 사이의 +/// 구독자는 모두 같은 원본을 나눠 본다. +/// +/// **새 구독자는 마지막 값을 곧바로 받는다.** 그러지 않으면 나중에 열린 화면이 +/// 다음 갱신이 올 때까지 빈 상태로 남는다 — 이클립스는 몇 분에 한 번 바뀌므로 +/// 그 빈 상태가 오래간다. +class SharedStream { + SharedStream(this._open); + + /// 원본을 여는 함수. 실제로 구독자가 생겼을 때만 불린다. + final Stream Function() _open; + + StreamSubscription? _sub; + final _out = StreamController.broadcast(); + + T? _last; + bool _hasLast = false; + + /// 구독자 수. 0 이 되면 원본을 닫는다. + int _listeners = 0; + + Stream get stream => Stream.multi((controller) { + _listeners += 1; + _openIfNeeded(); + // 늦게 붙은 구독자에게 현재 값을 먼저 준다. + if (_hasLast) controller.add(_last as T); + final sub = _out.stream.listen( + controller.add, + onError: controller.addError, + onDone: controller.close, + ); + controller.onCancel = () async { + await sub.cancel(); + _listeners -= 1; + if (_listeners == 0) await _close(); + }; + }); + + void _openIfNeeded() { + if (_sub != null) return; + _sub = _open().listen( + (value) { + _last = value; + _hasLast = true; + if (!_out.isClosed) _out.add(value); + }, + onError: (Object error, StackTrace stack) { + if (!_out.isClosed) _out.addError(error, stack); + }, + ); + } + + Future _close() async { + await _sub?.cancel(); + _sub = null; + // 마지막 값은 버린다. 다시 열릴 때 옛 값을 먼저 주면 화면이 잠깐 과거를 그린다. + _last = null; + _hasLast = false; + } + + /// 저장소가 버려질 때 부른다. + Future dispose() async { + await _close(); + await _out.close(); + } +} diff --git a/app/test/shared_stream_test.dart b/app/test/shared_stream_test.dart new file mode 100644 index 0000000..7819adc --- /dev/null +++ b/app/test/shared_stream_test.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +import 'package:fancake/core/data/shared_stream.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 실시간 구독 공유 — 같은 문서를 여러 화면이 볼 때 원본 구독은 하나만 연다. +void main() { + group('구독 공유 — 여러 화면이 원본 하나를 나눠 본다', () { + test('여러 화면이 같은 원본 하나를 나눠 쓴다', () async { + // 화면마다 snapshots() 를 열면 한 팬덤에 리스너가 화면 수만큼 붙는다. + var opened = 0; + final controller = StreamController.broadcast(); + addTearDown(controller.close); + final shared = SharedStream(() { + opened += 1; + return controller.stream; + }); + + final a = []; + final b = []; + final subA = shared.stream.listen(a.add); + final subB = shared.stream.listen(b.add); + await Future.delayed(Duration.zero); + + controller.add(7); + await Future.delayed(Duration.zero); + + // 원본은 한 번만 열렸는데 두 화면 모두 값을 받았다. + expect(opened, 1); + expect(a, [7]); + expect(b, [7]); + + await subA.cancel(); + await subB.cancel(); + }); + + test('늦게 붙은 화면이 현재 값을 곧바로 받는다', () async { + final controller = StreamController.broadcast(); + addTearDown(controller.close); + final shared = SharedStream(() => controller.stream); + + final first = []; + final subA = shared.stream.listen(first.add); + await Future.delayed(Duration.zero); + controller.add(42); + await Future.delayed(Duration.zero); + + // 뒤늦게 열린 화면이 다음 갱신까지 빈 상태로 남으면 안 된다. + final late = []; + final subB = shared.stream.listen(late.add); + await Future.delayed(Duration.zero); + expect(late, [42]); + + await subA.cancel(); + await subB.cancel(); + }); + + test('마지막 화면이 떠나면 원본을 닫는다', () async { + var cancelled = false; + final controller = StreamController( + onCancel: () => cancelled = true, + ); + addTearDown(controller.close); + final shared = SharedStream(() => controller.stream); + + final sub = shared.stream.listen((_) {}); + await Future.delayed(Duration.zero); + expect(cancelled, isFalse); + + await sub.cancel(); + await Future.delayed(Duration.zero); + // 남겨 두면 화면이 없는 동안에도 읽기 비용이 계속 든다. + expect(cancelled, isTrue); + }); + }); +}