5. The first frame and after
5. The first frame and after
Section titled “5. The first frame and after”Docs 2–4 dealt with the network. This one deals with everything that happens on the device itself: what still runs before the first frame can exist, what moved behind it, and what the app does the instant home appears.
What is still allowed before runApp
Section titled “What is still allowed before runApp” main() │ │ init() DI registration — 106 lazy closures, │ nothing constructed, effectively free │ await initial() │ │ │ ├─ WidgetsFlutterBinding.ensureInitialized() │ ├─ FirebaseMessaging.onBackgroundMessage(…) registration only │ ├─ await getVersion() ~5 ms platform channel │ │ ▲ KEPT awaited: the force-update gate reads app_version │ │ synchronously, so it must be populated before the splash runs │ │ │ ├─ Firebase.initializeApp().then(Marktag · NotificationServices) │ │ ▲ fire-and-forget — was already correct, left alone │ │ │ ├─ await getApplicationDocumentsDirectory() │ ├─ Hive.init + 3 × registerAdapter │ ├─ await _runDownloadsMigrationIfNeeded() │ │ ▲ MUST stay pre-runApp: it deletes the downloads box out from │ │ under the new adapter shape, so it has to precede the open │ └─ await _initHive() │ ├─ openBox<CacheContentEntity>('downloads') with retry │ └─ Future.wait([ ← was sequential │ openBox<FirebaseNotification>('notifications'), │ ApiCacheStore.open(), ← NEW, doc 3 │ ]) │ ▼ runApp(MyApp()) ← first frame │ ▼ addPostFrameCallback ──► _initDownloadsSubsystem() ← MOVED HERE ├─ _initBackgroundDownloader() 6 s cap └─ DownloadManager.initialize() 15 s capWhy the downloads subsystem moved
Section titled “Why the downloads subsystem moved”Those two calls carried 21 seconds of timeout budget between them, in
front of the first frame — and DownloadManager.initialize() calls:
await FileDownloader().permissions.request(PermissionType.notifications);On Android 13+ that can raise a system permission dialog before any Flutter frame exists. The user’s first impression of the app was an OS prompt over a blank window.
Nothing in there is needed until the Downloads screen is opened. The
contract gained a ready future so anyone who gets there first still
waits for a reconciled list:
abstract class DownloadManager { Future<void> initialize(); Future<void> get ready; // completes when initialize() has been attempted}DownloadScreenBloc.GetDownloadList awaits it. ready completes in a
finally, so a failed init unblocks its waiters rather than hanging them
forever.
Landing on home
Section titled “Landing on home”Lazy tabs
Section titled “Lazy tabs”IndexedStack builds every child. Doc 1 showed what that meant. The fix
is a visited-set:
final Set<int> _visitedTabs = {0};
Widget _tab(int index, Widget Function() child) { if (bottom_bar_index.value == index) _visitedTabs.add(index); if (!_visitedTabs.contains(index)) return const SizedBox.shrink(); return child();} BEFORE AFTER ────── ───── [0] HomeScreen built [0] HomeScreen built [1] ShortsScreen built ←── [1] SizedBox.shrink() 100 × VideoPlayerController (built on first tap, + 5 full video downloads then kept alive) [2] CategoryScreen built ←── [2] SizedBox.shrink() + 1 API call [3] ProfileSettings built ←── [3] SizedBox.shrink() + /user/profiles AGAINOnce visited, a tab stays in the set — so tab switching keeps its state
exactly as IndexedStack always did. The only thing that changed is
when a tab is first built.
Paint before you fetch
Section titled “Paint before you fetch” BEFORE AFTER ────── ───── await getPopupNotification() ← net _publishFeed(emit) ← PAINT popup_response.fold(…) │ navItems = getIt<List<NavItem>>() ▼ homePageList from globals await getPopupNotification() emit(HomeScreenLoaded) ← PAINT popup_response.fold(…) _checkPopupForLocation(…)The feed was already in memory. It sat behind a request nothing on that frame depended on, and the user watched a blank black screen for it.
Two more fixes went in alongside:
- A failed popup fetch used to
emit(HomeScreenError(...))— blanking a perfectly good feed because a marketing popup failed to load. It is now logged and ignored. getIt<List<NavItem>>()was replaced with a direct read ofnavItemListGlobal. GetIt caches the first resolved instance, and that global is reassigned (not mutated) on every refresh — so the DI copy went permanently stale after the first read. This was a live bug, not just a startup one.
And the refresh swaps in
Section titled “And the refresh swaps in”_publishFeed is shared between the first paint and HomeFeedRefreshed,
so when home_feed_revision bumps (doc 3) the same code path re-emits with
fresh content. Tabs the user has already opened are preserved across the
swap rather than being reset to empty.
The two things the user actually sees
Section titled “The two things the user actually sees”The GIF
Section titled “The GIF”assets/client-assets/images/App-Intro.gif — 2.9 MB, 512×512, 250
frames — was drawn at 220 logical px with no cacheWidth, so every frame
decoded at full resolution.
Image.asset( "assets/client-assets/images/App-Intro.gif", width: 220.h, cacheWidth: (220 * MediaQuery.of(context).devicePixelRatio) .round().clamp(1, 512), fit: BoxFit.cover,)Image.asset directly rather than CustomImageView, because that shared
widget has no cacheWidth parameter and is used in dozens of places where
changing its signature would be a much larger blast radius.
This bounds decode cost to the size actually drawn. Re-encoding the asset smaller — or replacing it with Lottie, which is already a dependency — would be better still, but needs a new asset from design.
The white flash
Section titled “The white flash”Nobody had touched the native launch screens, so both platforms showed the stock template before the black Dart splash:
BEFORE AFTER ────── ───── Android (light mode) Android drawable-v21/launch_background.xml → @android:color/black → ?android:colorBackground (drawable/ was already black) → WHITE under Theme.Light.NoTitleBar │ ▼ visible flash black Dart splash black → black, no seam
iOS iOS LaunchScreen.storyboard backgroundColor → 0,0,0 backgroundColor = 1,1,1 (WHITE) + three 68-byte placeholder LaunchImage PNGs │ ▼ visible flash black Dart splashDone by hand — two attribute edits — rather than by adding
flutter_native_splash and running its generator. Same result, no new
dependency, no generated files to keep in sync.
Next: Before and after — the comparison, how to measure it, and what is still open.