Migrate paginated feeds to Paging 3 + @Immutable + fix Main-thread stutters#90
Merged
malkoG merged 15 commits intohackers-pub:mainfrom Apr 15, 2026
Merged
Conversation
dalinaum
commented
Apr 13, 2026
Collaborator
|
혹시 리베이스 해주실 수 있나요? |
Contributor
Author
|
자정 근처에 확인해볼게요. |
Lay the backend surface for migrating feeds to Paging 3. Follow-up commits will convert Notifications, Timeline, PostDetail, Explore, and Profile screens to consume Flow<PagingData<T>>. Paging infra (data/paging/): - CursorPagingSource<T> + cursorPager factory. PagingConfig defaults tuned from Network Inspector findings (~15 KB per post): pageSize=20, prefetchDistance=20 (one full page ahead), initialLoadSize=30 (1.5 pages), placeholders disabled. - PostOverlay + PostOverlayStore + applyOverlay(s) for optimistic share / reaction mutations without invalidating the PagingSource. - Flow<PagingData<Post>>.distinctByEffectiveId() — drops duplicates by displayed post id (sharedPost.id ?: id). Layered on top of the existing repository-level outer-id dedup from PR hackers-pub#83; this one handles repost pairs (different outer ids, same sharedPost.id) at the paging boundary. Repository adapters (extension fns on HackersPubRepository): - notificationsPage, personalTimelinePage, publicTimelinePage, localTimelinePage, postRepliesPage, actorPostsPage, actorNotesPage, actorArticlesPage — one-liners so ViewModels can call cursorPager { repository.fooPage(it) } directly. New repository methods: - getPostReplies(postId, after): paginates replies independently of the main post body (separate PostReplies query). - getActorPosts(handle, after): lightweight POSTS-tab query that avoids re-fetching the actor header per page (mirrors getActorNotes / getActorArticles). - Both wrap their response-mapping block in withContext(Dispatchers.Default) — Apollo .execute() already dispatches network off-Main, but the subsequent nested Post construction + Instant.parse is CPU-bound and would otherwise run on the caller's dispatcher (viewModelScope defaults to Main.immediate). Existing methods are unchanged; they'll adopt the same pattern as they're exercised by paging callers. GraphQL operations: - Add ActorPosts query (lightweight POSTS-tab variant). - Add PostReplies query (replies-only subset of PostDetail). - PostFields / SharedPostFields mentions(first: 20) unchanged. Models: - Annotate every data class with @immutable (including new ArticleDraft, AccountLink, ActorField added post-branch) so Compose skip analysis treats feed types as stable. Dependencies: - androidx.paging:paging-runtime-ktx + paging-compose 3.3.5 - androidx.paging:paging-testing, mockk 1.13.12, turbine 1.1.0, kotlinx-coroutines-test for ViewModelTest groundwork. - composeCompiler reports block added to build.gradle.kts in commented form — uncomment to regenerate stability reports when debugging recomposition. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Convert NotificationsViewModel to expose Flow<PagingData<Notification>> via cursorPager + the notificationsPage repository adapter. Remove the manual pagination UiState (hasNextPage, endCursor, loadMore, refresh). NotificationsScreen consumes LazyPagingItems with collectAsLazyPagingItems and gates loading/error/empty UI on items.loadState.refresh. Branch order: Error+empty -> NotLoading+empty+endOfPaginationReached -> empty (pre-load Loading/NotLoading) -> content. This ordering prevents the initial-load flicker where pre-Loading NotLoading frames briefly render the empty state. Empty-state refresh button from PR hackers-pub#91 preserved via ErrorMessage(onRefresh = { items.refresh() }). markAsSeen triggers from a LaunchedEffect keyed on items.loadState.refresh once the initial refresh settles with items present. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Convert TimelineViewModel to expose Flow<PagingData<Post>> via cursorPager + distinctByEffectiveId + PostOverlayStore. Optimistic share and reaction mutations now route through overlayStore.mutate / .clear so the PagingSource stays valid across mutations. TimelineScreen consumes LazyPagingItems with collectAsLazyPagingItems. LoadState branch order (Error+empty -> NotLoading+endOfPaginationReached -> empty -> content) prevents initial-load flicker. The postedAt and tabRetapped LaunchedEffects hook into snapshotFlow on items.loadState instead of the old isRefreshing flag. Preserved from main: - PR hackers-pub#97 draft badge: TimelineUiState.draftCount + init { loadDraftCount() } + LaunchedEffect(Unit) { viewModel.loadDraftCount() } in the Screen so returning from the Drafts screen refreshes the badge count. - PR hackers-pub#93 recommended actors button placed between article and settings. - PR hackers-pub#91 empty-state refresh button — wired to items.refresh() (paging) instead of the old viewModel.refresh(). - All top-bar callbacks (onComposeArticleClick, onRecommendedActorsClick, onSettingsClick, onComposeClick, etc.) retain their signatures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PostDetailViewModel now exposes replies as Flow<PagingData<Post>> built from cursorPager + postRepliesPage + distinctByEffectiveId. The main post, reactionGroups, shares/quotes sheets, delete flow, and translation state remain in UiState since they are single-instance optimistic updates that don't benefit from the overlay pattern. PostDetailScreen keeps PR hackers-pub#89's PostDetailStateDispatch as the loading/ error/content gate. collectAsLazyPagingItems is called inside the content lambda so the replies pager is tied to the post lifecycle. Pull-to-refresh invokes both viewModel.refresh() (main post) and replies.refresh(). PostDetailContent signature changes: replies parameter is now LazyPagingItems<Post>, replacing List<Post> + hasMoreReplies + isLoadingMoreReplies + onLoadMoreReplies. loadState.append drives the in-list loading footer. PostDetailContentTest.kt updated to provide replies via flowOf(PagingData.from(...)).collectAsLazyPagingItems(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ExploreViewModel.posts is now a Flow<PagingData<Post>> driven by a selectedTab StateFlow through flatMapLatest — switching between LOCAL and GLOBAL cancels the previous pager and starts a fresh one, so stale emissions never leak across tabs. cachedIn is placed inside flatMapLatest per Paging 3 guidance. Overlays are combined after the pager switch so optimistic share/reaction updates persist across tab changes within the same post id. Manual scroll-based loadMore, refresh state flags, and endCursor tracking are removed; LazyPagingItems + prefetchDistance drive pagination. ExploreScreen uses collectAsLazyPagingItems with the six-case LoadState switch (Error+empty, NotLoading+endOfPaginationReached+empty, empty else (pre-Loading frame), and content) to prevent initial-load flicker. Empty-state refresh button from PR hackers-pub#91 preserved via ErrorMessage(onRefresh = { items.refresh() }). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ProfileViewModel exposes three independent Flow<PagingData<Post>> (postsTab, notesTab, articlesTab), each built from cursorPager + actor*Page repository adapter + distinctByEffectiveId. A shared PostOverlayStore is layered on all three flows, so an optimistic react/share on one tab propagates across the others without an extra network round trip (replaces the old updatePostInAllTabs helper). Actor header state (bio, fields, account links, follow/block/isViewer) remains in UiState since it's one-shot and needs direct optimistic updates that don't fit the overlay pattern. Preserved from PR hackers-pub#88: - ProfileTab enum and tab selection state - ProfileHeader, ProfileTabBar, ProfileAttachments, ProfileFields composables copied verbatim into the new Screen layout. Preserved from PR hackers-pub#91: - ErrorMessage(onRefresh = { items.refresh() }) on empty state. Dead-fields cleanup (from the original d160bfe commit, applied now that Paging 3 replaces the imperative POSTS pagination): - operations.graphql: ActorByHandle no longer selects posts(first, after) or declares \$after — actor*Page queries paginate instead. - HackersPubRepository.getProfile loses its postsAfter parameter and stops mapping actor.posts. - Models.ProfileResult drops posts, hasNextPage, endCursor. fields and accountLinks remain. - ProfileScreenTest fixtures updated for the shorter ProfileResult. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Media thumbnails (53197d9): - MediaImage uses thumbnailUrl ?: url for feed grid / card preview (smaller payload, faster decode: ~2000x2000 source displayed at ~150 dp used to pull ~500 KB of data per image). - QuotedPostPreview first-media thumbnail uses the same fallback. - Full-resolution url is still used by MediaPreviewDialog (tap-to-zoom), DownloadManager, and external-share intents. ViewModel tests (e7a98f8): - testutil/MainDispatcherRule: swaps Dispatchers.Main for a TestDispatcher for the test lifetime. - data/paging/PostOverlayTest: pure-function coverage for applyOverlay / applyOverlays (direct vs sharedPost propagation, partial overlay, clamp-at-zero). - ui/screens/{timeline,explore,postdetail,profile,notifications}/ *ViewModelTest: picker visibility, share/unshare/toggleReaction repository call verification, sharedPost target id handling, optimistic-mutation failure paths, tab selection state, delete flow, follow/unfollow/block routing, refresh re-fetch. Adjusted to the post-migration ViewModel shapes: selectedTab is a standalone StateFlow (not a UiState field) on Explore and Profile; ProfileResult no longer has posts/hasNextPage/endCursor. - TimelineViewModelTest stubs repository.getArticleDrafts() since the init block now calls loadDraftCount (hackers-pub#97). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two runBlocking calls were blocking the Main thread during app startup
(MT-1, MT-2 from the stutter audit):
1. AppModule's Authorization Interceptor called
runBlocking { sessionManager.sessionToken.first() } on every request,
which meant the interceptor paid a DataStore file-read cost (blocking
the caller's thread) each time. DataStore itself dispatches its I/O on
Dispatchers.IO, but runBlocking pins the caller until that I/O
completes - so a request initiated from viewModelScope on Main
(Main.immediate) stalled the UI thread.
2. MainActivity.requestNotificationPermissionIfNeeded() called
runBlocking { sessionManager.isLoggedIn.first() } in the onCreate
critical path, delaying first-frame rendering.
Fixes:
- SessionManager now exposes sessionTokenState: StateFlow<String?> and
isLoggedInState: StateFlow<Boolean>, both backed by stateIn on an
application-scoped coroutine (Eagerly). First emit populates them
asynchronously; the interceptor and activity read .value snapshots
with zero blocking.
- AppModule's Authorization Interceptor reads
sessionManager.sessionTokenState.value; pre-login the value is null
and no Authorization header is attached (login API doesn't need one,
so behavior is unchanged).
- MainActivity.requestNotificationPermissionIfNeeded() wraps the login
check in lifecycleScope.launch { ... } so onCreate returns without
waiting on DataStore.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
calculateCacheSize and clearCache previously launched on
viewModelScope (Main.immediate) and synchronously walked the cache
directory + deleted files on the Main thread. With a populated cache,
both operations could stall frame delivery for tens to hundreds of ms
— observable as a stutter when opening Settings or tapping
Clear Cache.
Wrap the blocking bodies in withContext(Dispatchers.IO) { … }. The
StateFlow updates after each operation still land on the caller's
dispatcher (Main), which is correct for UI state. Apollo store
operations (apolloStore.clearAll()) go inside the same IO block
since they touch the SQLite-backed normalized cache.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…e time)
Three per-recomposition CPU-bound computations were running on every
frame of a recomposition, contributing to scroll/nav stutters (MT-4,
MT-5, MT-7 from the stutter audit):
1. PostDetailScreen's DateTimeFormatter.ofPattern(...).withZone(...)
was allocated inside the composable body. Pattern compile + zone
lookup on every recomposition. Wrapped in remember { }.
2. ReactionPicker computed two collection operations - .filter +
.associateBy for the emoji -> group map, and .filter + .sortedWith
for the selected groups - on every recomposition. Wrapped each in
remember(reactionGroups) { } so they only re-compute when the
source list changes.
3. NotificationsScreen's NotificationItem called formatRelativeTime
(Duration.between + Instant.now + when-branch) per item per
recomposition. Wrapped the call site with
remember(notification.created) { formatRelativeTime(...) }. Labels
no longer tick in real time, but notifications scroll/interact
will trigger fresh composition often enough in practice.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1d0b9d9 to
e501343
Compare
Contributor
Author
|
이왕 컨플릭난 김에 더 수정을 요청했는데 범위가 커서 코드를 좀 살펴보고 테스트를 해보고 드래프트를 풀겠습니다. @malkoG |
Fixes the Notifications spinner freeze reported on first entry. The Paging 3 PagingSource.load coroutine is launched on Main.immediate via cachedIn(viewModelScope), so any CPU work between Apollo's .execute() completing and the Result returning ran on Main. For Notifications the per-edge toNotification() does Instant.parse + nested actors/post mapping — a 20-item initial page accumulated ~50-200 ms of Main-thread CPU, long enough that the loading spinner animation visibly froze. Wrap the response-mapping block (not the whole function) in withContext(Dispatchers.Default) for every repository method that constructs our domain models from Apollo responses: - getPublicTimeline, getLocalTimeline, getPersonalTimeline - getNotifications <-- direct cause of the frozen-spinner symptom - getPostDetail - getProfile - getActorArticles, getActorNotes getPostReplies and getActorPosts already had this pattern applied at migration time; the existing methods were deferred pending profiling (MT-6 in the stutter audit). User report confirmed the hit, so apply uniformly. Notes: - Apollo .execute() stays outside the withContext block — it already dispatches network to OkHttp's thread pool, so wrapping it would only add unnecessary context switches. - response.hasErrors() branch stays outside too; just accessing the error object is cheap. - Existing hackers-pub#83 defensive .distinctBy { it.id } is preserved inside the mapping block. - For methods with an early `return Result.failure(...)` from a null check, replaced with `return@withContext Result.failure(...)` so the control flow stays inside the suspending lambda. - Unit tests use runTest + StandardTestDispatcher and stub the repository with mockk, so the dispatcher change is transparent. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous "one-full-page-ahead" prefetch (20) combined with an inflated initialLoadSize (30) caused cascading prefetch right after first render. Because the server caps every page at first: 20, an initial load actually returned 20 items — but PagingConfig had asked for 30, which Paging treated as an under-filled window and immediately queued an append. With prefetchDistance=20 and only 20 items loaded, any small scroll satisfied `distance-from-end < prefetchDistance`, so pages 2, 3, 4… fired back-to-back before the user reached the tail. New values: - prefetchDistance = 5. Roughly half a viewport; the next page fetches when the user is genuinely approaching the tail instead of on the first render. The Paging library guidance is "a bit more than what's visible" — notifications / timeline show 3-8 items at once, so 5 is the right shoulder. - initialLoadSize = 20. Matches pageSize and the server's fixed first page, eliminating the desired-vs-delivered mismatch that forced an immediate append on first load. - pageSize = 20. Unchanged (server constraint). - enablePlaceholders = false. Unchanged (no skeleton UI). This complements the previous commit's Dispatchers.Default offloading: that commit stops any single page's mapping from freezing the UI; this commit stops us from firing extra pages that don't benefit the user and multiply server load. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
HtmlContent and CodeBlockView previously parsed inline during composition
(remember { parseHtmlToAnnotatedString(...) } / remember { parseShikiCodeBlock(...) }).
The memoization helped on revisits but the first composition of each
newly-visible LazyColumn item paid the full parse cost on Main. For rich
posts (headings, lists, mentions, code blocks) this is 10-50 ms per item;
during fast scroll the costs accumulate into visible stutters.
Switch to a two-track approach:
- Process-level LruCache keyed by (html, themed colors) so once an item
is parsed anywhere in the app (Timeline / Explore / Profile / PostDetail
/ Notifications) it stays hot across screen switches.
- Sync parse during composition only when the cache already has the
result or the html is short enough (< 500 chars for bodies,
< 300 chars for code blocks) that inline parsing is effectively free.
- Otherwise render an empty AnnotatedString placeholder on the first
frame and parse on Dispatchers.Default via produceState; the Text
populates on the next frame. During scroll this keeps the frame
callback unblocked so the LazyColumn can keep rendering without
stalling.
Cache sizes (256 bodies, 128 code blocks) cover the active set across
bottom-nav tabs comfortably; eviction falls back to reparse, which stays
off Main. The cache key includes theme colors so dark-mode toggling
correctly reparses instead of serving stale highlighting.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The generated Apollo types mark `iri` and `type` as non-null on the
relevant fragments, and `toPost()` never returns null — it always
constructs a `Post` from the fields it's given. The prior
`mapNotNull { ... toPost(...) }` and `?.toString()` / `type?.toString()`
were therefore dead safe-guards that only served to obscure the real
shape of the data.
- 9 `.mapNotNull { edge -> edge.node.postFields.toPost(...) }` calls
simplified to `.map { ... }` across getPublicTimeline,
getLocalTimeline, getPersonalTimeline, searchPosts, getPostDetail's
replies block, getPostReplies, getActorPosts, getActorArticles,
getActorNotes, getPostQuotes.
- `iri?.toString()` -> `iri.toString()` in the two `toPost()`
overloads.
- `mediaType = type?.toString()` -> `mediaType = "$type"` in `toMedia()`.
No functional change; just makes static types match runtime.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
malkoG
reviewed
Apr 15, 2026
| * under-filled on first load.) | ||
| * - `enablePlaceholders = false` — existing UI has no skeleton support. | ||
| */ | ||
| fun <T : Any> cursorPager( |
Collaborator
There was a problem hiding this comment.
T : Any 가 들어간 코드도 이후의 변경사항으로서 이슈 파일링을 해두는게 좋겠죠???
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
기존 수동 커서 페이지네이션(
UiState.posts+loadMore()+endCursor)을 Jetpack Paging 3 로 마이그레이션. 병행해서:@Immutable어노테이션)PagingConfig튜닝 (cascading prefetch 방지)이전 PR #89 (null-safety + UI tests) 와는 독립 브랜치. 최신 main 위에 rebase 완료 — #88(Profile tabs), #89(PostDetailStateDispatch), #91(empty-state refresh), #92(ArticleCard styling), #93(recommended actors), #97(article drafts) 등의 상위 변경과 공존하도록 각 화면/ViewModel 을 수동 각색.
커밋 구성
Paging 3 마이그레이션
Add Paging 3 infrastructure, repository adapters, and @ImmutableCursorPagingSource,PostOverlay, 8개 repository adapter, 신규 GraphQL 쿼리(PostReplies,ActorPosts), 모든 도메인 모델에@Immutable, paging/test 의존성Migrate Notifications to Paging 3Flow<PagingData<Notification>>,markAsSeen을LaunchedEffect(loadState)로 이동Migrate Timeline to Paging 3 (preserve drafts & recommended actors)draftCount배지 + PR #93 추천 버튼 보존,PostOverlayStore기반 낙관 업데이트Migrate PostDetail replies to Paging 3 (preserve StateDispatch)PostDetailStateDispatch그대로 유지Migrate Explore to Paging 3selectedTab.flatMapLatest탭별 독립 pagerMigrate Profile to Paging 3 (preserve tabs, attachments, fields)PostOverlayStore, PR #88 attachments/fields 유지, dead-field cleanupPolish: thumbnailUrl in media grid, ViewModel tests, overlay coveragecomposeCompiler리포트 블록(주석)Main-thread stutter 수정
Remove runBlocking from Main-thread startup pathsAppModuleAuth Interceptor +MainActivity.onCreatecritical path →SessionManager.sessionTokenStateStateFlow 로 전환Offload SettingsViewModel file I/O to Dispatchers.IOMemoize Compose hot paths (DateTimeFormatter, ReactionPicker, relative time)remember { }/remember(key)로 매 recomposition CPU 작업 제거Offload repository response mapping to Dispatchers.Default (MT-6)getNotifications/getPersonalTimeline/getPublicTimeline/getLocalTimeline/getPostDetail/getProfile/getActorArticles/getActorNotes응답 매핑 블록만withContext(Dispatchers.Default)로 이관 (Apollo.execute()는 이미 self-dispatching 이라 감싸지 않음)Tune PagingConfig: prefetchDistance 20→5, initialLoadSize 30→20prefetchDistance = 20이 첫 렌더 직후 page 2/3/4 를 chained 로 발동시키던 현상 제거. 서버first:20과initialLoadSize일치시켜 desired-vs-delivered mismatch 해소Offload HTML and code-block parsing to Dispatchers.DefaultHtmlContent/CodeBlockView가 매 새 아이템마다 Main 에서 파싱 → 스크롤 스터터. 프로세스 LRU 캐시 + 길이 임계값 기반 비동기 분기 도입변경된 화면
Flow<PagingData<Notification>>로 전환.markAsSeen은LaunchedEffect(loadState)로 이동Flow<PagingData<Post>>+PostOverlayStore. draft 배지(#97) / 추천 액터 버튼(#93) 그대로 유지UiState, 답글만Flow<PagingData<Post>>. #89PostDetailStateDispatch유지 (content 람다 내에서collectAsLazyPagingItems). 새PostReplies쿼리로 답글 페이지네이션 시 메인 post 중복 fetch 제거selectedTab.flatMapLatest로 탭별 독립 pagerFlow<PagingData<Post>>. 공유PostOverlayStore로 탭 간 낙관적 업데이트 자동 전파. #88ProfileAttachments/ProfileFields/ProfileTabBarcomposable 그대로 유지. 새ActorPosts쿼리핵심 인프라 추가
CursorPagingSource—(after: String?) -> Result<CursorPage<T>>람다 하나 받는 제네릭PagingSource. 각 페이지네이션 엔드포인트는 어댑터 확장함수 한 줄로 연결.cursorPager팩토리 — 표준PagingConfig감싸는 헬퍼. 튜닝된 기본값:pageSize = 20(서버first:20고정과 일치)prefetchDistance = 5(뷰포트 절반 정도의 headroom — 스크롤 끝 근처에서만 다음 페이지 trigger)initialLoadSize = 20(서버의 실제 returned page size 와 일치 → desired-vs-delivered mismatch 없음)PostOverlay+PostOverlayStore— 낙관적 share/reaction 업데이트를PagingData스트림에map으로 투영. 실패 시 overlay 만 clear 하면 서버 authoritative 상태로 자동 복귀.PagingSourceinvalidate 없이 즉각 UI 반영.distinctByEffectiveId—sharedPost?.id ?: id기준으로 페이지 간 중복 필터. LazyColumn "Key was already used" 크래시와 "원본+repost wrapping 중복 표시" 두 버그 동시 해결. Fix duplicate post crash in LazyColumn #83 의 Repository-레벨 outer-id 디듀프(distinctBy { it.id }) 는 방어선으로 유지하고, 이 헬퍼는 paging layer 에 추가로 덧댐.HtmlContent/CodeBlockViewLRU 캐시 — 프로세스 레벨LruCache<Key, AnnotatedString>(256 / 128 entries). 키에 테마 색상 포함 → 다크모드 토글 시 정확히 재파싱.Main-thread stutter 수정 상세 (L / M / N 커밋)
실기 Notifications 진입 시 "로딩 스피너가 빙글 돌다가 얼어붙는" 증상에서 출발. 감사 결과 세 가지가 누적되는 구조:
L — Repository 응답 매핑이 Main 에서 실행 (MT-6, CRITICAL)
PagingSource.load()는cachedIn(viewModelScope)체인을 타고 Main.immediate 에서 호출됨..execute()자체는 proper suspend (OkHttp Dispatcher 자체 스레드 +DefaultApolloStore의Dispatchers.Default) 로 Main 을 block 하지 않음. 하지만.execute()가 resume 된 이후 매핑 블록(mapNotNull { toPost(...) },Instant.parse(...), nested actors/post 매핑 등) 은 caller dispatcher (= Main) 에서 수행됨.toNotification()이 아이템당Instant.parse+ 6개 sealed 서브타입 분기 + nested post 추출 → 초기 2030 아이템이면 누적 50200 ms Main 점유. 스피너 애니메이션이 그 기간 동안 frame callback 을 받지 못해 얼어붙음.withContext(Dispatchers.Default)로 이관..execute()는 바깥에 유지 (blanket wrap 은 context switch 낭비).getPostReplies/getActorPosts는 마이그레이션 시점에 이미 적용돼 있었고, 나머지 6개는 MT-6 deferred 로 남아있던 것을 이번에 일괄 적용.M —
PagingConfig가 cascading prefetch 유발prefetchDistance = 20+pageSize = 20→ 첫 렌더 직후 distance-from-end < 20 이 바로 충족되어 page 2 가 즉시 trigger. page 2 도착 후 또 scroll → page 3 → page 4 chained.initialLoadSize = 30도 문제: 서버는first:20고정이라 30 요청해도 20 만 돌려줌. Paging 이 "window 가 under-filled" 로 판정하여 첫 로드 직후 append 를 즉시 schedule.prefetchDistance = 5(뷰포트 절반 headroom, 끝 근처 스크롤에만 발동),initialLoadSize = 20(서버 실제 크기와 일치). 페이지네이션이 사용자 스크롤에만 반응하게 됨.N —
HtmlContent/CodeBlockView가 Main 에서 파싱remember(html) { parseXxx(...) }로 메모이즈 돼 있었지만, 첫 composition 시 (새로 보이는 LazyColumn 아이템마다)remember람다가 Main 에서 실행됨.LruCache(HtmlContent256 /CodeBlockView128 entries, key =(html, themed colors)) + 길이 기반 분기:< 500/< 300) → 동기 파싱 (placeholder flash 없음)produceState+withContext(Dispatchers.Default)로 비동기. 빈AnnotatedString을 첫 프레임에 띄우고 파싱 완료 후 다음 프레임에 Text 업데이트.Apollo / Retrofit 에 대한 중요한 전제
apolloClient.query/mutation(...).execute()는 proper suspend. Retrofit 의 suspend fun 과 동일하게 Main 을 block 하지 않음. 따라서 Repository 전체를withContext(Dispatchers.IO)로 감싸는 blanket 접근은 하지 않음. 매핑 블록만 선택적 래핑 (Dispatchers.Default— CPU-bound 이므로 IO 가 아닌 Default).GraphQL 쿼리 변경
클라이언트 쿼리만 변경, 서버 스키마 변경 없음.
신규
PostReplies($id, $after)— 답글만 반환 (PostDetail 의 풀 post + reaction + 답글 세트 대신)ActorPosts($handle, $after)— Posts 탭 페이지네이션 (ActorNotes/ActorArticles와 동일 패턴)기존 쿼리 trim
ActorByHandle에서posts(first:20, after:$after)와$after: String파라미터 제거 — 헤더 전용 쿼리로 단순화. 페이지네이션은ActorPosts가 담당.PostFields/SharedPostFields의mentions(first: 20)는 그대로 유지.Payload 절감 효과
MediaImage):thumbnailUrl선호로 2000×2000 이미지를 150dp 에 표시하던 낭비 제거Compose Stability
domain/model/Models.kt의 모든data class에@Immutable추가 (Post, Actor, Media, ReactionGroup, Notification + 그 sealed 하위, ArticleDraft, AccountLink, ActorField 등).app/build.gradle.kts에composeCompiler { reportsDestination = ... }블록 추가 (주석 처리됨 — 활성화하면app/build/compose_compiler/에 stability 리포트 생성).이미지/미디어
MediaImage와QuotedPostPreview의 그리드 이미지가 기존엔 풀 해상도url을 사용.thumbnailUrl있으면 우선 사용하도록 변경 — 150dp 표시에 2000×2000 내려받던 낭비 제거. 풀뷰(MediaPreviewDialog) / 다운로드 (DownloadManager) / 외부 공유 intent 는 여전히url원본.UI 버그 수정
LazyPagingItems 초기 상태 플리커
loadState.refresh가NotLoading(endOfPaginationReached=false)→Loading→ 결과 순으로 emit 되는데, 초기NotLoading + itemCount==0이 첫 프레임에 매칭되어 "no posts" 엠티 UI 가 순간적으로 깜빡였음.when브랜치 순서 재정렬로 해결. Timeline / Explore / Notifications / Profile 동일 패턴 적용.테스트
테스트 의존성 추가
kotlinx-coroutines-test—runTest/StandardTestDispatcherturbine— Flow 테스트 (follow-up 용)mockk— Repository / SessionManager 등 모킹androidx.paging:paging-testing— PagingData 스냅샷 (follow-up 용)추가된 테스트
PostOverlayTestapplyOverlay/applyOverlays— 직접 + sharedPost 전파, partial override, 0 clampTimelineViewModelTestinit { loadDraftCount() }시getArticleDrafts()스텁ExploreViewModelTestselectTab(탭은 이제 독립StateFlow), reaction picker, toggleFavourite ❤️PostDetailViewModelTestcanDelete세션 핸들 매칭, 낙관적 share/unshare UiState mutate, delete 플로우, toggleReaction 그룹 생성/제거ProfileViewModelTestselectTab(독립StateFlow), follow/unfollow/block → repository 라우팅 + actionError, refresh 재fetch.ProfileResult의 posts 필드 제거된 새 시그니처 반영NotificationsViewModelTestmarkAsSeen→NotificationStateManager위임PostDetailContentTest(기존 #89)replies: List<Post>→LazyPagingItems<Post>시그니처 대응총 110 테스트 통과 (기존 + 신규 합산).
공통 유틸
testutil/MainDispatcherRule—viewModelScope가Dispatchers.Main을 쓰므로 테스트 중TestDispatcher로 스왑하는 JUnit rule.스코프 외 (별도 PR 후보)
ImageLoaderFactory도입 (메모리/디스크 캐시 크기 튜닝). 썸네일 적용 효과 본 뒤 필요 시.SettingsViewModel.kt:91packageInfo.longVersionCode가 API 28 필요하나minSdk=26. 별도 PR.Test plan
./gradlew :app:assembleDebug— 빌드 성공./gradlew :app:testDebugUnitTest— 110/110 통과PostDetailStateDispatch→ 답글 pagination, 메인 post share/reaction 낙관적 업데이트🤖 Generated with Claude Code