<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Flutter | kaguc — Writing to understand systems.</title><link>https://kaguc.com/tag/flutter/</link><atom:link href="https://kaguc.com/tag/flutter/index.xml" rel="self" type="application/rss+xml"/><description>Flutter</description><generator>Hugo Blox Builder (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Fri, 28 Aug 2026 00:00:00 +0000</lastBuildDate><image><url>https://kaguc.com/media/logo.svg</url><title>Flutter</title><link>https://kaguc.com/tag/flutter/</link></image><item><title>A reference design: quotes and watchlist MVP in Flutter</title><link>https://kaguc.com/blog/flutter-trading-mvp/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/flutter-trading-mvp/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/flutter-trading-mvp-zh/">中文&lt;/a>. The closing part of &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>.&lt;/em>&lt;/p>
&lt;p>The preceding articles each argue a mechanism. This one commits to a design: a market-data app with search, watchlists, and charts, built by two or three people, with trading added later.&lt;/p>
&lt;p>Committing matters. It&amp;rsquo;s easy to write &amp;ldquo;it depends&amp;rdquo;; the useful artifact is a specific plan, the reasoning that produced it, and an explicit account of what it refuses to build.&lt;/p>
&lt;h2 id="the-constraints-stated-honestly">The constraints, stated honestly&lt;/h2>
&lt;p>Before choosing anything, the six magnitudes from &lt;a href="https://kaguc.com/blog/trading-app-architecture/">the overview&lt;/a>, answered for &lt;em>this&lt;/em> product:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Constraint&lt;/th>
&lt;th>Our magnitude&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Freshness&lt;/td>
&lt;td>1-second quotes, a few hundred visible symbols&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Irreversibility&lt;/td>
&lt;td>none in v1 — read-only&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Catalog&lt;/td>
&lt;td>~150k instruments, searchable offline&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Platforms&lt;/td>
&lt;td>iOS + Android, one small team&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Teams&lt;/td>
&lt;td>one&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Threat&lt;/td>
&lt;td>public market data; user accounts in v2&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Five of six are small. That decides most of the architecture, and it decides it in the direction of &lt;em>less&lt;/em>.&lt;/p>
&lt;h2 id="the-design">The design&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">WebSocket ──▶ decode (Dart) ──▶ FrameCoalescer ──▶ QuoteStore ──▶ leaf widgets
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">REST ──▶ catalog delta sync ──▶ SQLite (bundled snapshot) ──▶ search
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Transport.&lt;/strong> One WebSocket for live quotes, REST for everything else. JSON at this rate is fine; a binary codec is a later optimization, not a starting decision.&lt;/p>
&lt;p>&lt;strong>Ingest.&lt;/strong> Decode on the UI isolate to begin with. At a few hundred messages a second this measures in microseconds. Move it to an isolate the day the profile says so — not before.&lt;/p>
&lt;p>&lt;strong>Conflation.&lt;/strong> The &lt;code>FrameCoalescer&lt;/code> from &lt;a href="https://kaguc.com/blog/flutter-highfreq-state/">the state article&lt;/a> sits between the socket and the store. This one is worth building on day one even at low rates: it is thirty lines, and it makes the render cost independent of the feed rate permanently.&lt;/p>
&lt;p>&lt;strong>State.&lt;/strong> Per-symbol &lt;code>ValueNotifier&lt;/code>s in a &lt;code>QuoteStore&lt;/code>; leaves subscribe individually. Page-level state can be whatever you like — the granularity discipline only matters on the hot path.&lt;/p>
&lt;p>&lt;strong>Catalog.&lt;/strong> A prebuilt SQLite file shipped as an asset, copied on first run, delta-synced by version, searched via FTS with pinyin and initials columns, per &lt;a href="https://kaguc.com/blog/flutter-offline-store/">the offline article&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Charts.&lt;/strong> &lt;code>CustomPainter&lt;/code> with a static layer and a live layer in separate repaint boundaries. Use a charting package first if it fits; write your own when you need indicator panes or drawing tools.&lt;/p>
&lt;p>&lt;strong>Structure.&lt;/strong> One package with clear folders. Not a monorepo — see below.&lt;/p>
&lt;h2 id="what-we-deliberately-do-not-build">What we deliberately do not build&lt;/h2>
&lt;p>This list is the actual content of the design. Anything can be added; the discipline is in the sequencing.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>No native code, no FFI.&lt;/strong> A 1 Hz feed does not justify a boundary that costs you stack traces and two toolchains.&lt;/li>
&lt;li>&lt;strong>No custom binary protocol.&lt;/strong> JSON over WebSocket until payload size or parse time is measurably a problem.&lt;/li>
&lt;li>&lt;strong>No shared cross-platform core.&lt;/strong> One Flutter codebase already covers both platforms; a shared domain layer solves a problem we don&amp;rsquo;t have.&lt;/li>
&lt;li>&lt;strong>No package-per-feature monorepo.&lt;/strong> One team, one app: &lt;a href="https://kaguc.com/blog/flutter-project-structure/">package boundaries&lt;/a> would be overhead, not enforcement.&lt;/li>
&lt;li>&lt;strong>No add-to-app.&lt;/strong> Nothing to embed into.&lt;/li>
&lt;li>&lt;strong>No certificate pinning.&lt;/strong> Public data, and pinning is an operational commitment we cannot yet honor. Standard TLS, done properly.&lt;/li>
&lt;li>&lt;strong>No order state machine.&lt;/strong> No orders. When they arrive, &lt;a href="https://kaguc.com/blog/order-reliability/">that article&lt;/a> becomes mandatory reading, not optional.&lt;/li>
&lt;/ul>
&lt;p>Every line is a decision we can reverse. That is the point of writing them down: someone will propose each one, and the answer should be &amp;ldquo;not yet, and here&amp;rsquo;s what would change my mind.&amp;rdquo;&lt;/p>
&lt;h2 id="build-order">Build order&lt;/h2>
&lt;p>&lt;strong>Phase 1 — catalog and search.&lt;/strong> Ship the snapshot, build search, list instruments. No live data yet. This proves the largest, least reversible asset (the database and its sync) before anything depends on it.&lt;/p>
&lt;p>&lt;strong>Phase 2 — live quotes.&lt;/strong> WebSocket, coalescer, per-symbol notifiers, watchlist screen. Now the reactive path exists end to end. Reconnect with backoff and resubscribe from the start; retrofitting reconnection is painful because it touches every subscription site.&lt;/p>
&lt;p>&lt;strong>Phase 3 — charts.&lt;/strong> History over REST, custom painter, two layers. The first screen where raster-thread performance matters, and the first place you profile on a mid-range device.&lt;/p>
&lt;p>&lt;strong>Phase 4 — accounts and orders.&lt;/strong> Auth, then the full &lt;a href="https://kaguc.com/blog/order-reliability/">order reliability&lt;/a> design: idempotency keys persisted before send, an unknown state, reconciliation on reconnect. This phase changes the threat model, so &lt;a href="https://kaguc.com/blog/client-security-model/">the security article&lt;/a> applies from its first commit.&lt;/p>
&lt;p>Phases 1-3 have no irreversible actions, which is why they can move fast. Phase 4 is where care stops being optional, and it deserves a slower cadence.&lt;/p>
&lt;h2 id="the-upgrade-path">The upgrade path&lt;/h2>
&lt;p>The design&amp;rsquo;s real quality is not what it does now but where it can grow without a rewrite. Each heavy element has a defined seam and a &lt;em>measured&lt;/em> trigger:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Element&lt;/th>
&lt;th>Where it plugs in&lt;/th>
&lt;th>Adopt when&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Isolate for decode&lt;/td>
&lt;td>behind the feed interface&lt;/td>
&lt;td>UI-thread profile shows decode in frame budget&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Binary protocol&lt;/td>
&lt;td>codec inside the feed&lt;/td>
&lt;td>payload size or parse time measurably hurts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Native/FFI ingest&lt;/td>
&lt;td>replaces the feed implementation&lt;/td>
&lt;td>conflation in Dart can&amp;rsquo;t keep up — likely never at this rate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Feature packages&lt;/td>
&lt;td>extract from folders&lt;/td>
&lt;td>two people repeatedly collide, or a layer needs isolated tests&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Shared domain core&lt;/td>
&lt;td>extract &lt;code>domain/&lt;/code>&lt;/td>
&lt;td>a second client appears that must not diverge&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Order machinery&lt;/td>
&lt;td>new module&lt;/td>
&lt;td>the first irreversible action ships&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The seams are what you design deliberately today: a feed exposed as an interface rather than a concrete socket class, catalog access behind a repository, and business rules kept out of widgets. None of those cost anything now, and each one is what makes a later change an extraction instead of a rewrite.&lt;/p>
&lt;h2 id="the-one-thing-to-keep">The one thing to keep&lt;/h2>
&lt;p>If a reader takes a single idea from this series: &lt;strong>write down your six magnitudes before you choose anything, then adopt each heavy element on the day a measurement demands it.&lt;/strong>&lt;/p>
&lt;p>Large architectures are not more advanced. They are the same constraints at full size, plus years of accumulated migration state. Copying one early means paying its coordination costs with none of its scale — which is the most common and most expensive mistake in this whole subject.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;p>&lt;em>API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).&lt;/em>&lt;/p>
&lt;ul>
&lt;li>M. Fowler, &lt;a href="https://martinfowler.com/bliki/Yagni.html">&amp;ldquo;Yagni&amp;rdquo;&lt;/a>, 2015 — the cost of a presumptive feature is not only building it but carrying it.&lt;/li>
&lt;li>D. McKinley, &lt;a href="https://boringtechnology.club/">&amp;ldquo;Choose Boring Technology&amp;rdquo;&lt;/a>, 2015 — innovation tokens, and why JSON over a WebSocket is the right first answer.&lt;/li>
&lt;li>J. Bezos, &lt;a href="https://s2.q4cdn.com/299287126/files/doc_financials/annual/2015-Letter-to-Shareholders.PDF">Amazon 2015 Letter to Shareholders&lt;/a>, 2016 — Type 1 (one-way door) vs Type 2 (two-way door) decisions.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Previous: &lt;a href="https://kaguc.com/blog/flutter-project-structure/">Flutter project structure&lt;/a> · Back to &lt;a href="https://kaguc.com/blog/trading-app-architecture/">the overview&lt;/a>.&lt;/em>&lt;/p></description></item><item><title>Channels, Pigeon, and FFI: crossing to native without paying per tick</title><link>https://kaguc.com/blog/flutter-native-channels/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/flutter-native-channels/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/flutter-native-channels-zh/">中文&lt;/a>. Flutter implementation notes for &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>; see also &lt;a href="https://kaguc.com/blog/cross-platform-boundary/">the cross-platform boundary&lt;/a>.&lt;/em>&lt;/p>
&lt;p>There are three ways out of Dart, and they are not interchangeable. Choosing well is less about performance benchmarks than about matching the mechanism to how often you cross and what you need on the other side.&lt;/p>
&lt;h2 id="the-three-mechanisms">The three mechanisms&lt;/h2>
&lt;p>&lt;strong>Platform channels&lt;/strong> (&lt;code>MethodChannel&lt;/code>, &lt;code>EventChannel&lt;/code>) send messages through the engine to platform code. They are &lt;strong>asynchronous&lt;/strong>, they &lt;strong>serialize&lt;/strong> arguments through a codec, and they hop threads. That makes them ideal for platform capabilities — permissions, the camera, biometrics, share sheets — where calls are occasional and the work is on the platform side anyway. Cost is dominated by the per-call overhead, not by argument size.&lt;/p>
&lt;p>&lt;strong>Pigeon&lt;/strong> is code generation on top of channels. You declare an interface once, and it generates typed Dart and platform code. The runtime cost is the same as a hand-written channel; what you get is the elimination of an entire class of bug — misspelled method names, mismatched argument shapes, silent type coercion — that channels invite because they are stringly typed and untyped in both directions. If you have more than a couple of channel methods, use it.&lt;/p>
&lt;p>&lt;strong>&lt;code>dart:ffi&lt;/code>&lt;/strong> calls C directly. It is &lt;strong>synchronous&lt;/strong>, has &lt;strong>no serialization&lt;/strong>, and can share memory with Dart via typed data. It is the only option when you need to touch a native library&amp;rsquo;s API surface directly or move large buffers cheaply. The catch is exactly its virtue: a synchronous call runs on the calling isolate, so a slow FFI call blocks whatever isolate invoked it — and if that&amp;rsquo;s the UI isolate, you have dropped frames. As of Flutter 3.47 / Dart 3.13 (August 2026), the supported way to bundle the native side is a build hook (&lt;code>hook/build.dart&lt;/code>) in an FFI package — &lt;code>flutter create --template=package_ffi&lt;/code>, recommended since Flutter 3.38 — which replaces the old per-platform plugin scaffolding for C code.&lt;/p>
&lt;h2 id="the-number-that-decides-it">The number that decides it&lt;/h2>
&lt;p>The cost of crossing is paid &lt;strong>per call&lt;/strong>. This one fact settles most designs.&lt;/p>
&lt;p>A feed delivering 5,000 messages a second, marshalled individually over a channel, is 5,000 serialization round trips and thread hops per second — and it will be slower than doing all the work in Dart, despite native code being &amp;ldquo;faster.&amp;rdquo; You would have paid thousands of transitions to save some decoding.&lt;/p>
&lt;p>The same feed, decoded and conflated natively with Dart reading a snapshot &lt;strong>once per frame&lt;/strong>, is 60 crossings a second carrying a batch. Same data, two orders of magnitude fewer boundary costs.&lt;/p>
&lt;p>So the rule is the one from &lt;a href="https://kaguc.com/blog/realtime-data-pipeline/">the pipeline article&lt;/a>, stated for Flutter: &lt;strong>cross on the frame, never on the tick.&lt;/strong> Design the native interface as a small number of coarse operations that move a batch, not as a stream of individual events or a set of property getters.&lt;/p>
&lt;h2 id="a-concrete-shape-for-a-high-rate-feed">A concrete shape for a high-rate feed&lt;/h2>
&lt;p>If you do end up with native ingest, the interface that works looks like this: native owns the socket, decodes, detects gaps, and maintains a current-value table. Dart asks for what changed since it last looked.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="kd">class&lt;/span> &lt;span class="nc">QuoteBridge&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">QuoteBridge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">_lib&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">NativeLib&lt;/span> &lt;span class="n">_lib&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">/// Copies changed quotes into a reusable buffer. One crossing per frame.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="n">drainInto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Float64List&lt;/span> &lt;span class="n">out&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">_lib&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">drain&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">out&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">address&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">out&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">length&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two properties matter here. The buffer is &lt;strong>reused&lt;/strong> rather than allocated per call, so you are not creating garbage 60 times a second. And the call returns a &lt;em>count&lt;/em> into a preallocated region rather than constructing objects across the boundary, because allocating Dart objects from native code per element reintroduces the per-item cost you were avoiding.&lt;/p>
&lt;p>Then keep the FFI call off the UI isolate if it can ever be slow:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// Long-running or blocking work belongs on its own isolate.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">final&lt;/span> &lt;span class="n">decoded&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">Isolate&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">run&lt;/span>&lt;span class="p">(()&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">heavyDecode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bytes&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For native code that needs to call &lt;em>into&lt;/em> Dart — a completion callback, a push notification from the feed — use &lt;code>NativeCallable&lt;/code>, which gives you a function pointer that safely re-enters the Dart isolate. Do not try to invoke Dart from an arbitrary native thread without it.&lt;/p>
&lt;h2 id="memory-ownership-must-be-explicit">Memory ownership must be explicit&lt;/h2>
&lt;p>FFI removes the serialization boundary, and with it the automatic memory management you were implicitly relying on. Decide and document, for every buffer crossing the line, &lt;strong>who allocates and who frees&lt;/strong>.&lt;/p>
&lt;p>The two patterns that hold up:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Dart allocates, native fills.&lt;/strong> Dart owns a typed-data buffer for its whole lifetime and passes it down. Simplest, and my default — there is no ambiguity about lifetime.&lt;/li>
&lt;li>&lt;strong>Native allocates, Dart frees explicitly.&lt;/strong> Wrap it so release is not optional: &lt;code>using((arena) { ... })&lt;/code> for scoped allocations, or attach a &lt;code>NativeFinalizer&lt;/code> so a dropped Dart object releases its native peer.&lt;/li>
&lt;/ul>
&lt;p>The failure here is not a crash you catch in testing. It&amp;rsquo;s a slow leak that shows up as a memory graph climbing over an hour of use, which is precisely the session length you never test.&lt;/p>
&lt;h2 id="eventchannel-has-no-backpressure">EventChannel has no backpressure&lt;/h2>
&lt;p>Worth stating on its own, because it surprises people: an &lt;code>EventChannel&lt;/code> fed faster than Dart consumes will queue. There is no built-in mechanism for the consumer to say &amp;ldquo;slow down,&amp;rdquo; so a fast native producer turns into unbounded buffering and then a memory problem. (The symptom is on record as far back as flutter/flutter#33640: a native producer feeding an &lt;code>EventChannel&lt;/code> continuously, and memory climbing at a steady rate.)&lt;/p>
&lt;p>If native is the producer of a high-rate stream, &lt;strong>conflate on the native side&lt;/strong> and let Dart pull, or push at a rate you control (once per frame) rather than at the rate data arrives. Applying backpressure is the producer&amp;rsquo;s job here because the transport won&amp;rsquo;t do it for you.&lt;/p>
&lt;h2 id="choosing-briefly">Choosing, briefly&lt;/h2>
&lt;ul>
&lt;li>Occasional platform capability → &lt;strong>Pigeon-generated channel&lt;/strong>. Type safety is free; the call rate is irrelevant.&lt;/li>
&lt;li>A handful of calls, no codegen appetite → a plain &lt;code>MethodChannel&lt;/code> is fine. Keep the method surface small.&lt;/li>
&lt;li>Large buffers, or a native library you must talk to directly → &lt;strong>FFI&lt;/strong>, with explicit ownership and a helper isolate for anything slow.&lt;/li>
&lt;li>High-rate stream → native conflates, Dart drains &lt;strong>once per frame&lt;/strong> over FFI. Never per-message channels.&lt;/li>
&lt;li>Anything you can do in Dart at acceptable cost → &lt;strong>do it in Dart.&lt;/strong> The boundary is a permanent tax on debugging, crash reporting, and build complexity, and it should be justified by a measurement rather than an intuition about speed.&lt;/li>
&lt;/ul>
&lt;p>That last one is the most common mistake. A stack trace stops at the boundary, your crash reporter needs separate symbol uploads for each side, and a reproduction now involves two toolchains. Cross when the profile says to, and not because native feels faster.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;p>&lt;em>API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).&lt;/em>&lt;/p>
&lt;ul>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/platform-integration/platform-channels">Writing custom platform-specific code&lt;/a> — &lt;code>MethodChannel&lt;/code> / &lt;code>EventChannel&lt;/code>, codecs, and threading rules.&lt;/li>
&lt;li>&lt;a href="https://pub.dev/packages/pigeon">&lt;code>pigeon&lt;/code>&lt;/a> on pub.dev — typed code generation over platform channels.&lt;/li>
&lt;li>Dart docs, &lt;a href="https://dart.dev/interop/c-interop">C interop using dart:ffi&lt;/a> — &lt;code>@Native&lt;/code>, &lt;code>NativeCallable&lt;/code>, &lt;code>NativeFinalizer&lt;/code>, and &lt;code>Arena&lt;/code> / &lt;code>using&lt;/code>.&lt;/li>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/platform-integration/bind-native-code">Bind to native code using FFI&lt;/a> and Dart docs, &lt;a href="https://dart.dev/tools/hooks">Hooks&lt;/a> — &lt;code>hook/build.dart&lt;/code> and the &lt;code>package_ffi&lt;/code> template.&lt;/li>
&lt;li>Dart docs, &lt;a href="https://dart.dev/language/isolates">Isolates&lt;/a> — messages are copied between isolates; &lt;code>Isolate.run&lt;/code> transfers its result instead. See also &lt;a href="https://api.dart.dev/dart-isolate/TransferableTypedData-class.html">&lt;code>TransferableTypedData&lt;/code>&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/flutter/flutter/issues/33640">flutter/flutter#33640&lt;/a> — steady memory growth from a native producer feeding an &lt;code>EventChannel&lt;/code> faster than Dart consumes.&lt;/li>
&lt;li>J. Waldo, G. Wyant, A. Wollrath, S. Kendall, &lt;a href="https://scholar.harvard.edu/files/waldo/files/waldo-94.pdf">&amp;ldquo;A Note on Distributed Computing&amp;rdquo;&lt;/a>, Sun Microsystems Laboratories TR-94-29, 1994 — a boundary between address spaces cannot be papered over; latency and partial failure are paid per call.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Previous: &lt;a href="https://kaguc.com/blog/flutter-highfreq-state/">Flutter under a firehose&lt;/a> · Next: &lt;a href="https://kaguc.com/blog/flutter-chart-rendering/">Charts and custom painting&lt;/a>&lt;/em>&lt;/p></description></item><item><title>Charts in Flutter: custom painting that holds 60fps</title><link>https://kaguc.com/blog/flutter-chart-rendering/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/flutter-chart-rendering/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/flutter-chart-rendering-zh/">中文&lt;/a>. Flutter implementation notes for &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>.&lt;/em>&lt;/p>
&lt;p>Most Flutter performance advice is about the UI thread: fewer rebuilds, smaller subtrees. A chart moves the problem to the &lt;strong>raster thread&lt;/strong>, where the question is not how many widgets you built but how much work you asked the GPU to do, and how often.&lt;/p>
&lt;h2 id="dont-compose-a-chart-out-of-widgets">Don&amp;rsquo;t compose a chart out of widgets&lt;/h2>
&lt;p>The instinct is to make each candle a widget. Six hundred candles then become six hundred widgets to build, lay out, and paint, plus their parents — and every pan gesture redoes all of it.&lt;/p>
&lt;p>A chart is one drawing, not many objects. Use a single &lt;code>CustomPainter&lt;/code> and draw into a canvas. You give up the framework&amp;rsquo;s layout and hit-testing for that region, and in exchange you get a cost proportional to &lt;em>pixels&lt;/em> rather than to &lt;em>elements&lt;/em>, plus complete control over what happens per frame.&lt;/p>
&lt;p>The widget tree remains the right tool for the toolbar, the legend, and the crosshair readout. It is the wrong tool for the series itself.&lt;/p>
&lt;h2 id="shouldrepaint-is-the-whole-game">&lt;code>shouldRepaint&lt;/code> is the whole game&lt;/h2>
&lt;p>This is the method that decides whether your chart is cheap or ruinous, and returning &lt;code>true&lt;/code> — the safe-looking answer — means repainting on every frame forever.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">class&lt;/span> &lt;span class="nc">CandlePainter&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">CustomPainter&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">CandlePainter&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="kd">required&lt;/span> &lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">series&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">required&lt;/span> &lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">viewport&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">required&lt;/span> &lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">revision&lt;/span>&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">CandleSeries&lt;/span> &lt;span class="n">series&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">Viewport&lt;/span> &lt;span class="n">viewport&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// pan/zoom
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">final&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="n">revision&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// bumped when series data changes
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="err">@&lt;/span>&lt;span class="n">override&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">bool&lt;/span> &lt;span class="n">shouldRepaint&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">CandlePainter&lt;/span> &lt;span class="n">old&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">old&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">revision&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="n">revision&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="n">old&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">viewport&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="n">viewport&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Compare a cheap &lt;strong>version counter&lt;/strong> rather than the data itself. Deep-comparing a few thousand candles on every frame is its own performance problem, and identity comparison silently fails the moment you mutate a list in place. A monotonic revision that you bump on every data change is unambiguous and free.&lt;/p>
&lt;h2 id="split-static-from-live">Split static from live&lt;/h2>
&lt;p>Within one chart, different parts change at wildly different rates. Grid lines, axis labels, and completed candles change when you pan or when a bar closes. The last price line, the crosshair, and the in-progress candle change constantly.&lt;/p>
&lt;p>Painting them together means the live element forces a repaint of everything.&lt;/p>
&lt;p>So stack two painters, each in its own &lt;code>RepaintBoundary&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="n">Stack&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">children:&lt;/span> &lt;span class="kd">const&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">RepaintBoundary&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">child:&lt;/span> &lt;span class="n">CustomPaint&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">painter:&lt;/span> &lt;span class="n">StaticChartPainter&lt;/span>&lt;span class="p">(...))),&lt;/span> &lt;span class="c1">// grid, closed candles
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">RepaintBoundary&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">child:&lt;/span> &lt;span class="n">CustomPaint&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">painter:&lt;/span> &lt;span class="n">LivePainter&lt;/span>&lt;span class="p">(...))),&lt;/span> &lt;span class="c1">// last price, crosshair
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now a tick repaints a layer containing a couple of lines. The expensive layer — thousands of candles and text labels — is cached as a texture and composited, not redrawn.&lt;/p>
&lt;p>This is the single highest-leverage change in most chart implementations, and it follows the same principle as &lt;a href="https://kaguc.com/blog/flutter-highfreq-state/">scoping rebuilds on the UI thread&lt;/a>: make the invalidated region match the thing that changed.&lt;/p>
&lt;h2 id="dont-allocate-inside-paint">Don&amp;rsquo;t allocate inside &lt;code>paint()&lt;/code>&lt;/h2>
&lt;p>&lt;code>paint()&lt;/code> may run 60 times a second. Everything created there becomes garbage at that rate, and GC pauses are precisely the tail-latency problem you are trying to avoid.&lt;/p>
&lt;p>Hoist &lt;code>Paint&lt;/code> objects to fields. Reuse &lt;code>Path&lt;/code> objects with &lt;code>reset()&lt;/code> instead of constructing new ones. Precompute your data-to-pixel transform once per paint rather than per candle. And for large point counts, prefer the batch primitives — &lt;code>drawRawPoints&lt;/code>, &lt;code>drawVertices&lt;/code>, or a single &lt;code>Path&lt;/code> — over thousands of individual &lt;code>drawLine&lt;/code> calls, since each canvas call carries overhead that dominates when the shape is tiny.&lt;/p>
&lt;h2 id="text-is-the-hidden-cost">Text is the hidden cost&lt;/h2>
&lt;p>Axis labels and price scales look free and are not: laying out text is one of the more expensive operations in a paint pass, and doing it for forty labels every frame will show up in your raster timings.&lt;/p>
&lt;p>Cache &lt;code>TextPainter&lt;/code> instances keyed by the string, and rebuild them only when the label set changes. Since price and time labels repeat heavily across frames, a small map gets you most of the way.&lt;/p>
&lt;h2 id="interaction-transform-the-canvas-not-the-data">Interaction: transform the canvas, not the data&lt;/h2>
&lt;p>Pan and zoom should change a viewport (an offset and a scale) that you apply to the canvas, not rewrite your data:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="n">canvas&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">save&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">canvas&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">translate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">viewport&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">offsetX&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="m">0&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">canvas&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">scale&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">viewport&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">scaleX&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="m">1&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// draw in data space
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n">canvas&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">restore&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Recomputing pixel positions for every candle on every gesture frame is the common cause of chart panning that feels heavy. Keep the model in data coordinates and let the canvas transform do the work.&lt;/p>
&lt;p>Hit testing runs the other way: convert the pointer position into data coordinates and binary-search the series for the nearest bar. That is a log-n lookup per gesture event instead of a scan.&lt;/p>
&lt;h2 id="shader-compilation-briefly">Shader compilation, briefly&lt;/h2>
&lt;p>Historically, the first frame that used a new shader could compile it on the spot and drop that frame — the classic &amp;ldquo;janks once, then never again&amp;rdquo; bug that only reproduces on a fresh install. Impeller — as of Flutter 3.47 (August 2026) the only renderer on iOS and the default on Android API 29+ — compiles shaders ahead of time and removes this class of stutter. Older Android devices still fall back to the OpenGL/Skia path, so if you support them, test a fresh install there.&lt;/p>
&lt;p>The practical guidance is short: stay on Impeller, and keep effects conventional. Exotic blend modes, large blurs, and heavy &lt;code>Opacity&lt;/code> over complex subtrees are still expensive on any backend, because they force offscreen render passes. &lt;code>Opacity&lt;/code> around a big subtree is the one most often reached for by accident — prefer opacity baked into a &lt;code>Paint&lt;/code> color when you are painting anyway.&lt;/p>
&lt;h2 id="measure-the-raster-thread">Measure the raster thread&lt;/h2>
&lt;p>Open the DevTools performance view and watch the two timelines separately. If UI frames are fine and raster frames are long, the fix is in this article: fewer layers, smaller invalidated regions, cheaper paint operations. If UI frames are long, your problem is upstream in the widget tree and belongs to &lt;a href="https://kaguc.com/blog/flutter-highfreq-state/">the state article&lt;/a>.&lt;/p>
&lt;p>Test with a realistic series — a few thousand bars — on a mid-range device. A chart that is smooth with 50 candles on a flagship tells you nothing about the case that matters.&lt;/p>
&lt;h2 id="when-to-just-use-a-library">When to just use a library&lt;/h2>
&lt;p>If you need a standard line or bar chart that updates a few times a minute, a charting package is the right answer and everything above is premature.&lt;/p>
&lt;p>Write your own when you need behavior the library does not have: synchronized indicator panes, custom drawing tools, precise crosshair semantics, tens of thousands of points, or ticking updates at frame rate. Those requirements are what justify owning a painter — not the belief that custom code will be faster in general.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;p>&lt;em>API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).&lt;/em>&lt;/p>
&lt;ul>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/perf/impeller">Impeller rendering engine&lt;/a> — the only renderer on iOS; default on Android API 29+ with an OpenGL fallback below; default on desktop since 3.47.&lt;/li>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/perf/shader">Shader compilation jank&lt;/a> — the first-run stutter, and Impeller as the fix.&lt;/li>
&lt;li>Flutter API, &lt;a href="https://api.flutter.dev/flutter/rendering/CustomPainter-class.html">&lt;code>CustomPainter&lt;/code>&lt;/a> and &lt;a href="https://api.flutter.dev/flutter/widgets/RepaintBoundary-class.html">&lt;code>RepaintBoundary&lt;/code>&lt;/a> — &lt;code>shouldRepaint&lt;/code> semantics and layer isolation.&lt;/li>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/perf/best-practices">Performance best practices&lt;/a> — &lt;code>const&lt;/code> widgets, &lt;code>RepaintBoundary&lt;/code>, &lt;code>itemExtent&lt;/code>, and why &lt;code>Opacity&lt;/code> and &lt;code>saveLayer&lt;/code> are expensive.&lt;/li>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/tools/devtools/performance">Use the Performance view&lt;/a> — UI and raster timelines, frame budget, and the rebuild counter.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Previous: &lt;a href="https://kaguc.com/blog/flutter-native-channels/">Channels, Pigeon, and FFI&lt;/a> · Next: &lt;a href="https://kaguc.com/blog/flutter-offline-store/">Offline storage and local search&lt;/a>&lt;/em>&lt;/p></description></item><item><title>Flutter project structure: packages, boundaries, and add-to-app</title><link>https://kaguc.com/blog/flutter-project-structure/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/flutter-project-structure/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/flutter-project-structure-zh/">中文&lt;/a>. Flutter implementation notes for &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>; the principle is in &lt;a href="https://kaguc.com/blog/modular-evolution/">Modularity and migration&lt;/a>.&lt;/em>&lt;/p>
&lt;p>Folder conventions do not create boundaries. A &lt;code>lib/features/&lt;/code> directory is a suggestion, and any file can import any other file across it. Once more than a few people work in a codebase, suggestions stop holding.&lt;/p>
&lt;p>Dart packages are the smallest unit where a boundary is actually enforced — a package cannot import what it doesn&amp;rsquo;t declare. That is the whole reason to reach for them.&lt;/p>
&lt;h2 id="a-package-per-feature">A package per feature&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">apps/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> trading_app/ # composition root: wiring, routes, flavors
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">packages/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> design_system/ # tokens, components, theme
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> domain/ # entities, value objects, rules — no Flutter import
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> data/ # repositories, api client, database
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> feature_quotes/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> feature_watchlist/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> feature_orders/
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>One rule makes this work: &lt;strong>features depend on &lt;code>domain&lt;/code>, &lt;code>data&lt;/code>, and &lt;code>design_system&lt;/code>; features never depend on each other; only the app depends on features.&lt;/strong>&lt;/p>
&lt;p>That single constraint is what buys you anything. It means a feature can be developed, tested, and deleted in isolation; that a change to &lt;code>feature_orders&lt;/code> cannot break &lt;code>feature_quotes&lt;/code> by construction; and that your build system can compile and test them in parallel.&lt;/p>
&lt;p>The corollary that people resist: if two features need the same thing, it belongs in &lt;code>domain&lt;/code> or a shared package — not imported sideways. Sideways imports are how a modular codebase quietly becomes a monolith with extra &lt;code>pubspec.yaml&lt;/code> files.&lt;/p>
&lt;p>Keep &lt;code>domain&lt;/code> free of Flutter entirely. It should be plain Dart: entities, rules, and the interfaces the outside world implements. That makes it fast to test without a widget binding, and it means the business logic could outlive a UI framework change — the practical form of &lt;a href="https://kaguc.com/blog/modular-evolution/">dependency inversion&lt;/a>.&lt;/p>
&lt;h2 id="enforce-the-rule-with-a-tool">Enforce the rule with a tool&lt;/h2>
&lt;p>A boundary nobody checks is a boundary that erodes in the first busy sprint. Make violations fail CI.&lt;/p>
&lt;p>The cheap version is a lint that forbids importing across features, using &lt;code>custom_lint&lt;/code> or an analysis option banning specific import paths. The cheaper version still: because features are separate packages, an undeclared import is already a compile error — you get most of the enforcement for free simply by &lt;em>not&lt;/em> putting everything in one package.&lt;/p>
&lt;p>Add a check that no package depends on the app, and that &lt;code>domain&lt;/code> depends on nothing but Dart. Both are one-line rules that catch real architectural drift.&lt;/p>
&lt;h2 id="routing-as-a-registry">Routing as a registry&lt;/h2>
&lt;p>If the app imports every feature&amp;rsquo;s screens directly, adding a feature means editing the app, and the app becomes the coupling point you tried to avoid.&lt;/p>
&lt;p>Instead have each feature expose its routes, and let the app compose them:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// in feature_quotes
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">final&lt;/span> &lt;span class="n">quotesRoutes&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">RouteDef&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">RouteDef&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/quotes/:symbol&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">args&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">QuoteDetailPage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">symbol:&lt;/span> &lt;span class="n">args&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;symbol&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="p">)),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">];&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// in apps/trading_app
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">final&lt;/span> &lt;span class="n">router&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AppRouter&lt;/span>&lt;span class="p">([&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">...&lt;/span>&lt;span class="n">quotesRoutes&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">...&lt;/span>&lt;span class="n">watchlistRoutes&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">...&lt;/span>&lt;span class="n">ordersRoutes&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">]);&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Navigation between features then goes through paths rather than through imported widget classes, which is what keeps features from depending on each other while still being able to link to one another. It also gives deep links a single place to be validated — and per &lt;a href="https://kaguc.com/blog/client-security-model/">the security article&lt;/a>, an incoming link must be treated as hostile input and re-checked at the destination, not trusted because it arrived.&lt;/p>
&lt;h2 id="composition-at-the-root-only">Composition at the root only&lt;/h2>
&lt;p>Dependency injection belongs in the app package. Features declare what they need in constructors; the root builds the object graph.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// apps/trading_app/lib/bootstrap.dart
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">final&lt;/span> &lt;span class="n">db&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">openCatalog&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">api&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ApiClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">baseUrl:&lt;/span> &lt;span class="n">env&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">apiUrl&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">quotes&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">QuoteRepository&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">api:&lt;/span> &lt;span class="n">api&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">db:&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">runApp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">App&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">quotes:&lt;/span> &lt;span class="n">quotes&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Reaching for a global service locator from inside a feature undoes this: the feature now has a hidden dependency that doesn&amp;rsquo;t appear in its constructor, cannot be substituted in a test without global setup, and makes the package non-portable. A locator at the composition root is fine. A locator called from a widget deep in a feature is a global variable with good manners.&lt;/p>
&lt;h2 id="add-to-app-and-what-it-costs">Add-to-app, and what it costs&lt;/h2>
&lt;p>If you are embedding Flutter into an existing native app — the &lt;a href="https://kaguc.com/blog/modular-evolution/">strangler-fig migration&lt;/a> — there are four costs to plan for.&lt;/p>
&lt;p>&lt;strong>Engine startup.&lt;/strong> The first Flutter screen pays engine initialization. Pre-warm a cached engine during app start so the first navigation isn&amp;rsquo;t the moment the user waits.&lt;/p>
&lt;p>&lt;strong>Multiple engines.&lt;/strong> &lt;code>FlutterEngineGroup&lt;/code> lets additional engines share the snapshot and most of the memory cost, which makes several live engines viable where independent engines would not be. It is still not free — prefer one engine with internal routing unless you genuinely need isolated screens.&lt;/p>
&lt;p>&lt;strong>Two navigation stacks.&lt;/strong> The native stack and Flutter&amp;rsquo;s router both exist, and the back button must behave sensibly across the seam. Decide early who owns the stack for a given flow, and route all back handling through that decision rather than patching cases as they surface.&lt;/p>
&lt;p>&lt;strong>Duplicated state.&lt;/strong> Session, theme, and locale now live on both sides. Make one side authoritative and push changes across a single channel; do not let both sides maintain their own copy and hope they agree.&lt;/p>
&lt;p>Pass initial route and arguments when creating the engine, and return results through one narrow channel rather than several ad-hoc ones — the coarse-boundary rule from &lt;a href="https://kaguc.com/blog/flutter-native-channels/">the channels article&lt;/a> applies to navigation too.&lt;/p>
&lt;h2 id="when-this-is-too-much">When this is too much&lt;/h2>
&lt;p>For one to three developers on a single app, this structure is overhead: more &lt;code>pubspec.yaml&lt;/code> files, more version wrangling (pub workspaces, Dart 3.6+, remove most of that — one resolution and one lockfile for the repo — but not the rest), slower cold builds, and indirection with nothing on the other side of it.&lt;/p>
&lt;p>Start with one package and folders. Extract a package when a boundary starts to hurt — you want to test a layer without a widget binding, two people keep colliding in the same area, or you need to reuse something in a second app. Those are observations. &amp;ldquo;We might grow&amp;rdquo; is not, and a small team maintaining a large team&amp;rsquo;s structure pays coordination costs for an organization it doesn&amp;rsquo;t have.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;p>&lt;em>API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).&lt;/em>&lt;/p>
&lt;ul>
&lt;li>Dart docs, &lt;a href="https://dart.dev/tools/pub/workspaces">Pub workspaces&lt;/a> (Dart 3.6+) — one resolution and one lockfile across the packages of a monorepo.&lt;/li>
&lt;li>&lt;a href="https://pub.dev/packages/custom_lint">&lt;code>custom_lint&lt;/code>&lt;/a> on pub.dev — project-specific lints, including import-boundary rules, that fail CI.&lt;/li>
&lt;li>R. C. Martin, &lt;a href="https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html">&amp;ldquo;The Clean Architecture&amp;rdquo;&lt;/a>, 2012 — the dependency rule: source dependencies point inward, toward policy.&lt;/li>
&lt;li>D. L. Parnas, &lt;a href="https://doi.org/10.1145/361598.361623">&amp;ldquo;On the Criteria To Be Used in Decomposing Systems into Modules&amp;rdquo;&lt;/a>, &lt;em>CACM&lt;/em> 15(12), 1972 — a module should hide a decision that is likely to change.&lt;/li>
&lt;li>M. Fowler, &lt;a href="https://martinfowler.com/articles/injection.html">&amp;ldquo;Inversion of Control Containers and the Dependency Injection pattern&amp;rdquo;&lt;/a>, 2004 — dependency injection vs service locator, and what each costs in testability.&lt;/li>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/add-to-app">Add Flutter to an existing app&lt;/a> and &lt;a href="https://docs.flutter.dev/add-to-app/multiple-flutters">Multiple Flutter instances&lt;/a> — engine warm-up, &lt;code>FlutterEngineGroup&lt;/code>, and the cost of several live engines.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Previous: &lt;a href="https://kaguc.com/blog/flutter-offline-store/">Offline storage and search&lt;/a> · Next: &lt;a href="https://kaguc.com/blog/flutter-trading-mvp/">A reference design for a quotes MVP&lt;/a>&lt;/em>&lt;/p></description></item><item><title>Flutter under a firehose: making rebuild scope match change scope</title><link>https://kaguc.com/blog/flutter-highfreq-state/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/flutter-highfreq-state/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/flutter-highfreq-state-zh/">中文&lt;/a>. Flutter implementation notes for &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>; the principle behind it is in &lt;a href="https://kaguc.com/blog/realtime-data-pipeline/">Real-time data pipelines&lt;/a>.&lt;/em>&lt;/p>
&lt;p>Put 300 symbols on screen, each updating a few times a second, and the standard Flutter approach falls over. Not because Flutter is slow — because the standard approach rebuilds a subtree proportional to the &lt;em>screen&lt;/em> on every change proportional to a &lt;em>single cell&lt;/em>.&lt;/p>
&lt;p>The fix is one idea applied ruthlessly: &lt;strong>the rebuilt subtree should be as small as the thing that changed.&lt;/strong> Everything below is a consequence.&lt;/p>
&lt;h2 id="why-the-ordinary-way-fails">Why the ordinary way fails&lt;/h2>
&lt;p>The usual shape is a store holding a map of quotes, exposed to the tree, with the list watching it. One tick arrives, the store notifies, and the list rebuilds — 300 rows, each with a handful of widgets, so a thousand-plus &lt;code>build()&lt;/code> calls for one number that moved.&lt;/p>
&lt;p>At 60 fps you have 16.6 ms per frame for everything: build, layout, paint, raster. A few hundred ticks a second against a screen-sized rebuild will not fit, and the symptom is the worst kind — it&amp;rsquo;s fine in development with mock data and janky exactly when the market is interesting.&lt;/p>
&lt;p>Note that this is not a criticism of any particular state management package. Provider, Bloc, Riverpod, and &lt;code>setState&lt;/code> all behave correctly here; the problem is that the &lt;em>granularity&lt;/em> was chosen wrong. You can build this badly with any of them, and well with most of them.&lt;/p>
&lt;h2 id="rebuild-only-the-leaf">Rebuild only the leaf&lt;/h2>
&lt;p>Give each symbol its own listenable, and let the widget that displays it be the only thing subscribed.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">class&lt;/span> &lt;span class="nc">QuoteStore&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">_notifiers&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">String&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ValueNotifier&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Quote&lt;/span>&lt;span class="o">&amp;gt;&amp;gt;&lt;/span>&lt;span class="p">{};&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ValueListenable&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Quote&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">listen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">String&lt;/span> &lt;span class="n">symbol&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_notifiers&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">putIfAbsent&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">symbol&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">ValueNotifier&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Quote&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">empty&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">symbol&lt;/span>&lt;span class="p">)));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// called from the feed, many times per second
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kt">void&lt;/span> &lt;span class="n">apply&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Quote&lt;/span> &lt;span class="n">q&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">n&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_notifiers&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">q&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">symbol&lt;/span>&lt;span class="p">];&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">n&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="kc">null&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// nobody is showing it — do no work
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">value&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">q&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// no visible change — do no work
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">q&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// notifies exactly one subtree
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">class&lt;/span> &lt;span class="nc">PriceCell&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">StatelessWidget&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">const&lt;/span> &lt;span class="n">PriceCell&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">store&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">symbol&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="k">super&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">key&lt;/span>&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">QuoteStore&lt;/span> &lt;span class="n">store&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="kt">String&lt;/span> &lt;span class="n">symbol&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="err">@&lt;/span>&lt;span class="n">override&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">Widget&lt;/span> &lt;span class="n">build&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">BuildContext&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">ValueListenableBuilder&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Quote&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nl">valueListenable:&lt;/span> &lt;span class="n">store&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">listen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">symbol&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nl">builder:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">q&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">__&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">Text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">q&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">last&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">toStringAsFixed&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="p">)),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The row, the list, and the page never rebuild. A tick for &lt;code>AAPL&lt;/code> rebuilds one &lt;code>Text&lt;/code>. The cost of an update is now proportional to what changed rather than to what is on screen — which is the property you were after.&lt;/p>
&lt;p>Two details in &lt;code>apply()&lt;/code> do a lot of work. Skipping symbols nobody is displaying turns an off-screen subscription leak into a no-op, and the equality check absorbs the very common case of a message that carries no visible change.&lt;/p>
&lt;h2 id="conflate-onto-the-frame-not-onto-the-tick">Conflate onto the frame, not onto the tick&lt;/h2>
&lt;p>Rebuilding one leaf per tick is cheap, but 20 ticks per second per symbol is still 19 wasted rebuilds — the screen only shows 60 frames, and a human perceives far fewer.&lt;/p>
&lt;p>So buffer incoming ticks and flush once per frame:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">class&lt;/span> &lt;span class="nc">FrameCoalescer&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">FrameCoalescer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">_flush&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="kt">void&lt;/span> &lt;span class="n">Function&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Iterable&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Quote&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="n">_flush&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">_pending&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">String&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Quote&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">{};&lt;/span> &lt;span class="c1">// keyed: last one wins
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kt">bool&lt;/span> &lt;span class="n">_scheduled&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">void&lt;/span> &lt;span class="n">add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Quote&lt;/span> &lt;span class="n">q&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_pending&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">q&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">symbol&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">q&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// conflation, for free
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">_scheduled&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_scheduled&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">SchedulerBinding&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">instance&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">scheduleFrameCallback&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">_&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_scheduled&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">batch&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_pending&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">values&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">toList&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">growable:&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_pending&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">clear&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_flush&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">batch&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The map does the conflation: ten ticks for one symbol between two frames collapse to one, automatically, because a later write replaces an earlier one under the same key. This is the &lt;em>register&lt;/em> case from &lt;a href="https://kaguc.com/blog/realtime-data-pipeline/">the pipeline article&lt;/a> expressed in nine lines.&lt;/p>
&lt;p>Now your update rate is bounded by the frame rate no matter what the market does. A volatile open costs slightly more decoding and exactly the same rendering.&lt;/p>
&lt;h2 id="keep-the-list-itself-cheap">Keep the list itself cheap&lt;/h2>
&lt;p>Even with perfect leaf updates, the list can undo your work:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>ListView.builder&lt;/code> with &lt;code>itemExtent&lt;/code>.&lt;/strong> A known extent lets Flutter skip measuring children to compute scroll geometry — a large win for uniform rows.&lt;/li>
&lt;li>&lt;strong>&lt;code>const&lt;/code> constructors&lt;/strong> everywhere they&amp;rsquo;re possible. A &lt;code>const&lt;/code> widget is skipped during rebuild instead of being reconstructed and compared.&lt;/li>
&lt;li>&lt;strong>&lt;code>RepaintBoundary&lt;/code> around independently-animating cells&lt;/strong>, so a flashing price cell doesn&amp;rsquo;t dirty the whole list&amp;rsquo;s layer. Use it deliberately: each boundary is a layer, and hundreds of them cost memory.&lt;/li>
&lt;li>&lt;strong>Hoist the notifier lookup.&lt;/strong> Calling &lt;code>listen(symbol)&lt;/code> inside &lt;code>build()&lt;/code> is fine when it&amp;rsquo;s a cheap map read, but never create the notifier during build if creating it has side effects like subscribing upstream.&lt;/li>
&lt;/ul>
&lt;h2 id="subscribe-from-the-widget-lifecycle">Subscribe from the widget lifecycle&lt;/h2>
&lt;p>Screen visibility should drive subscriptions, or you will pay for data nobody is looking at:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="err">@&lt;/span>&lt;span class="n">override&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kt">void&lt;/span> &lt;span class="n">initState&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">super&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">initState&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_sub&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">feed&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">subscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">widget&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">symbols&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c1">// reference-counted upstream
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="err">@&lt;/span>&lt;span class="n">override&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kt">void&lt;/span> &lt;span class="n">dispose&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_sub&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">cancel&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// last listener → unsubscribe
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">super&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">dispose&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Reference-count in the feed so two widgets showing the same symbol produce one upstream subscription, and the subscription ends when the second one goes. Combine that with app lifecycle: on &lt;code>AppLifecycleState.paused&lt;/code>, drop to a slow summary or stop entirely.&lt;/p>
&lt;h2 id="measure-the-right-thread">Measure the right thread&lt;/h2>
&lt;p>Flutter has two threads that matter, and they fail differently. The &lt;strong>UI thread&lt;/strong> runs your Dart: &lt;code>build&lt;/code>, layout, and the framework. The &lt;strong>raster thread&lt;/strong> turns the result into pixels.&lt;/p>
&lt;p>Everything above targets the UI thread. If DevTools shows the raster thread as the bottleneck, rebuild scope is not your problem — you have expensive painting, too many layers, or shader compilation, which is &lt;a href="https://kaguc.com/blog/flutter-chart-rendering/">the chart article&amp;rsquo;s&lt;/a> territory.&lt;/p>
&lt;p>Use the DevTools rebuild counter to confirm what you assume: put the app under a realistic feed, watch which widgets rebuild, and check the count matches the number of things that actually changed. When those two numbers agree, this problem is solved.&lt;/p>
&lt;h2 id="when-to-skip-all-of-it">When to skip all of it&lt;/h2>
&lt;p>If your data changes every few seconds, a single stream feeding one &lt;code>StreamBuilder&lt;/code> at the top of the page is correct and this article is overhead. Per-symbol notifiers, frame coalescing, and repaint boundaries are what you reach for when a measurement says a screen-sized rebuild no longer fits in a frame — not before.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;p>&lt;em>API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).&lt;/em>&lt;/p>
&lt;ul>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/perf/best-practices">Performance best practices&lt;/a> — &lt;code>const&lt;/code> widgets, &lt;code>RepaintBoundary&lt;/code>, &lt;code>itemExtent&lt;/code>, and why &lt;code>Opacity&lt;/code> and &lt;code>saveLayer&lt;/code> are expensive.&lt;/li>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/resources/architectural-overview">Flutter architectural overview&lt;/a> — the UI and raster threads, and what runs on each.&lt;/li>
&lt;li>Flutter API, &lt;a href="https://api.flutter.dev/flutter/widgets/ValueListenableBuilder-class.html">&lt;code>ValueListenableBuilder&lt;/code>&lt;/a> and &lt;a href="https://api.flutter.dev/flutter/scheduler/SchedulerBinding/scheduleFrameCallback.html">&lt;code>SchedulerBinding.scheduleFrameCallback&lt;/code>&lt;/a> — the two primitives the leaf-rebuild and frame-coalescing patterns are built on.&lt;/li>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/tools/devtools/performance">Use the Performance view&lt;/a> — UI and raster timelines, frame budget, and the rebuild counter.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Next in the Flutter notes: &lt;a href="https://kaguc.com/blog/flutter-native-channels/">Channels, Pigeon, and FFI&lt;/a> — how to cross to native without paying per tick.&lt;/em>&lt;/p></description></item><item><title>Shipping a database with your Flutter app: offline catalogs and instant search</title><link>https://kaguc.com/blog/flutter-offline-store/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/flutter-offline-store/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/flutter-offline-store-zh/">中文&lt;/a>. Flutter implementation notes for &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>; the principle is in &lt;a href="https://kaguc.com/blog/local-first-sync/">Local-first data&lt;/a>.&lt;/em>&lt;/p>
&lt;p>The design from &lt;a href="https://kaguc.com/blog/local-first-sync/">the local-first article&lt;/a> — ship a snapshot, sync deltas by version — has a specific and slightly fiddly Flutter implementation. Here it is concretely.&lt;/p>
&lt;h2 id="choosing-the-store">Choosing the store&lt;/h2>
&lt;p>&lt;code>sqflite&lt;/code> gives you SQLite with raw SQL. &lt;code>drift&lt;/code> sits on top of it (or on its own engine) and adds typed queries, migrations, and first-class isolate support. For a catalog with a schema you will evolve, drift&amp;rsquo;s migration story alone justifies it; for a handful of key-value settings, &lt;code>shared_preferences&lt;/code> and nothing else is correct.&lt;/p>
&lt;p>The mistake worth avoiding is reaching for a document store because it looks simpler, then discovering you need prefix search over several hundred thousand rows with ranking. That is a relational problem with a mature answer, and SQLite is on the device already.&lt;/p>
&lt;h2 id="bundle-the-snapshot-copy-once">Bundle the snapshot, copy once&lt;/h2>
&lt;p>Ship the prebuilt database as an asset and copy it into the app&amp;rsquo;s documents directory on first launch. The copy is necessary because assets are read-only and SQLite needs a writable file.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="n">Future&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">File&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">ensureCatalog&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">dir&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">getApplicationDocumentsDirectory&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">file&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">File&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dir&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">path&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;catalog.db&amp;#39;&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">shippedVersion&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">812&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// bumped when you re-cut the asset
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kd">await&lt;/span> &lt;span class="n">file&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">exists&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">_dataVersion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">file&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="n">shippedVersion&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">file&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// user&amp;#39;s copy is newer — keep it
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">bytes&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">rootBundle&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">load&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;assets/db/catalog.db&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">file&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">writeAsBytes&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bytes&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">buffer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">asUint8List&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nl">flush:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">file&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The comparison in the middle is the part people get wrong. After an app update you must not blindly overwrite: a user who has been syncing deltas for months may hold a &lt;em>newer&lt;/em> catalog than the one you just shipped. Copy only when the bundled snapshot is actually ahead, and let delta sync handle the rest.&lt;/p>
&lt;p>The other detail is that the copy happens on first launch, which is the moment you least want a long block. Copying tens of megabytes takes real time, so do it off the UI isolate and show a determinate first-run state rather than a frozen splash.&lt;/p>
&lt;h2 id="two-versions-never-conflated">Two versions, never conflated&lt;/h2>
&lt;p>Keep these separate, as the principle article insists:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Data version&lt;/strong> — how fresh the contents are. It moves when the server publishes changes, and drives delta sync. Store it in a metadata table you control.&lt;/li>
&lt;li>&lt;strong>Schema version&lt;/strong> — the shape of the tables. It moves when &lt;em>your app&lt;/em> changes, and drives migrations. SQLite&amp;rsquo;s &lt;code>user_version&lt;/code>, or drift&amp;rsquo;s &lt;code>schemaVersion&lt;/code>, is the natural home.&lt;/li>
&lt;/ul>
&lt;p>Conflating them produces one of two failures: an app update that discards a synced catalog to reinstall the snapshot, or a migration that never runs because the version counter was busy tracking data.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="err">@&lt;/span>&lt;span class="n">override&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">MigrationStrategy&lt;/span> &lt;span class="kd">get&lt;/span> &lt;span class="n">migration&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">MigrationStrategy&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nl">onUpgrade:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">from&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">to&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">from&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="m">3&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">m&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">addColumn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">instruments&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">instruments&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">board&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// data version untouched — the catalog contents survive
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="search-fts-plus-the-columns-you-actually-search">Search: FTS, plus the columns you actually search&lt;/h2>
&lt;p>Instant search over a few hundred thousand rows is what SQLite&amp;rsquo;s full-text search is for. Create an FTS5 table alongside the main one and query it, rather than running &lt;code>LIKE '%...%'&lt;/code>, which cannot use an index and degrades linearly.&lt;/p>
&lt;p>The interesting part is multilingual matching. Users search a Chinese instrument by its Chinese name, its English name, its ticker, its pinyin, or the initials of its pinyin — and they expect all five to work. That is not a tokenizer problem; it is a &lt;em>data&lt;/em> problem. Generate the variants at build time and store them as searchable columns:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>column&lt;/th>
&lt;th>example&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>symbol&lt;/code>&lt;/td>
&lt;td>&lt;code>00700&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>name_zh&lt;/code>&lt;/td>
&lt;td>腾讯控股&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>name_en&lt;/code>&lt;/td>
&lt;td>Tencent Holdings&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>pinyin&lt;/code>&lt;/td>
&lt;td>tengxunkonggu&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>initials&lt;/code>&lt;/td>
&lt;td>txkg&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Then one FTS query with prefix matching covers every entry style, and ranking is a matter of ordering by which column matched and by a popularity score you ship with the catalog. Doing this at build time rather than on the device keeps startup free and makes the behavior identical across platforms.&lt;/p>
&lt;h2 id="keep-queries-off-the-ui-isolate">Keep queries off the UI isolate&lt;/h2>
&lt;p>A query over a large table will block whatever isolate runs it, and on the UI isolate that means dropped frames during typing — the worst possible place, since search-as-you-type is exactly when responsiveness is judged.&lt;/p>
&lt;p>Drift supports running the database in a background isolate, which is the clean answer (drift 2.x, &lt;code>NativeDatabase.createInBackground&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">db&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AppDatabase&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">NativeDatabase&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">createInBackground&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">file&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then debounce input (a query per keystroke is wasted work), cancel superseded queries, and keep result sets small with &lt;code>LIMIT&lt;/code> — nobody scrolls past the first few dozen matches, and fetching a thousand rows to display twenty is pure cost.&lt;/p>
&lt;h2 id="sync-deltas-not-the-world">Sync deltas, not the world&lt;/h2>
&lt;p>Once the snapshot is in place, the client only asks what changed:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">since&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">meta&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">dataVersion&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">delta&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">api&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">catalogChanges&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">since:&lt;/span> &lt;span class="n">since&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kd">await&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">transaction&lt;/span>&lt;span class="p">(()&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">upsertAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">delta&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">changed&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">deleteAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">delta&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">removed&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">meta&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">setDataVersion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">delta&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">version&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c1">// same transaction, always
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The transaction boundary is the whole correctness argument: apply the rows and advance the marker atomically, or a crash between them leaves you permanently skipping a range. Express deltas as upserts and tombstones so re-applying an overlapping range is harmless.&lt;/p>
&lt;h2 id="what-stays-on-the-server">What stays on the server&lt;/h2>
&lt;p>The catalog is safe to hold locally because it is public, slow-moving, and read-only. Balances, positions, and order status are none of those things. Cache them for display if you like, but they are a rendering of a remote fact — refresh on resume, and never let a locally cached number participate in a decision.&lt;/p>
&lt;p>The user&amp;rsquo;s own data — a watchlist, notes — is a third category: small, user-owned, and written on the device. Give it its own table and its own two-way sync rather than mixing writes into the replicated catalog, which keeps the big replication one-way and conflict-free.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;p>&lt;em>API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).&lt;/em>&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://pub.dev/packages/drift">&lt;code>drift&lt;/code>&lt;/a> and its &lt;a href="https://drift.simonbinder.eu/isolates/">Isolates&lt;/a> guide — &lt;code>NativeDatabase.createInBackground&lt;/code>, &lt;code>DriftIsolate&lt;/code>, &lt;code>computeWithDatabase&lt;/code>; &lt;a href="https://pub.dev/packages/sqflite">&lt;code>sqflite&lt;/code>&lt;/a> for the raw-SQL alternative.&lt;/li>
&lt;li>SQLite, &lt;a href="https://www.sqlite.org/fts5.html">FTS5 Extension&lt;/a>; &lt;a href="https://www.sqlite.org/optoverview.html">The LIKE optimization&lt;/a> — why &lt;code>LIKE '%…%'&lt;/code> cannot use an index; &lt;a href="https://www.sqlite.org/pragma.html">&lt;code>PRAGMA user_version&lt;/code>&lt;/a> — the natural home for the schema version.&lt;/li>
&lt;li>M. Kleppmann, A. Wiggins, P. van Hardenberg, M. McGranaghan, &lt;a href="https://www.inkandswitch.com/local-first/">&amp;ldquo;Local-first software: You own your data, in spite of the cloud&amp;rdquo;&lt;/a>, Ink &amp;amp; Switch / Onward! 2019 — the source of the term and the seven ideals behind it.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Previous: &lt;a href="https://kaguc.com/blog/flutter-chart-rendering/">Charts in Flutter&lt;/a> · Next: &lt;a href="https://kaguc.com/blog/flutter-project-structure/">Project structure and add-to-app&lt;/a>&lt;/em>&lt;/p></description></item></channel></rss>