Skip to content

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.

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 cap

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.

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 AGAIN

Once 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.

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 of navItemListGlobal. 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.

_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.

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.

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 splash

Done 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.