Chuyển đến nội dung chính

Impeller and a janky frame: how to find where the 16ms actually went

A tester files a bug that says “the product list stutters when I scroll fast on the Pixel.” You search for flutter jank, and the first three results tell you to run flutter run --profile --cache-sksl, capture an SkSL file, and bundle it into your release build. You try it. The flag is gone, or it is ignored, and the stutter is exactly where it was.

That advice was correct for Skia. It is not correct for your app, because your app is almost certainly rendering with Impeller, and Impeller does not compile shaders at runtime at all. The Flutter docs are direct about it: Impeller “precompiles a smaller, simpler set of shaders at engine-build time so they don’t compile at runtime.” There is nothing to warm up. The --cache-sksl and --bundle-sksl-path workflow is a Skia-only mechanism, and issue #140310 exists specifically because people kept trying to use it with Impeller.

So one entire category of jank is gone. The other categories are not. A build() method that does JSON parsing is still slow. An IntrinsicHeight around a big Row is still an extra layout pass. Canvas.saveLayer is still, in the framework’s own words, “one of the most expensive methods in the Flutter framework.” A 4032×3024 photo drawn into a 96-pixel avatar is still a bad idea. Impeller changed which problems you have, not whether you have them.

This is the diagnostic path for the ones that are left: get a reproducible measurement, work out which thread ran long, then narrow to a named widget. Everything after that is mechanical.

What Impeller removed, and what it left behind

Impeller’s stated design goal is predictable cost. From the docs: “Impeller compiles all shaders and reflection offline at build time. It builds all pipeline state objects upfront. The engine controls caching and caches explicitly.” That is the sentence that killed the first-run-of-an-animation stutter — the one where the first time a user opened a page with a blur or a gradient, the frame took 200ms while the GPU driver compiled a program.

Where it runs, as of the current docs:

PlatformRendererNotes
iOSImpeller onlyThe docs say there is “no ability to switch to Skia”
Android API 29+Impeller, by defaultDefault for iOS and Android API 29+ since Flutter 3.27
Older / non-Vulkan AndroidAutomatic fallbackDocs: “Impeller falls back to the legacy OpenGL renderer”
macOS, Linux, WindowsImpeller, by default”as of Flutter 3.47”
WebSkia”It might use Impeller in the future”

Two practical consequences. First, if you still see a Shader compilation marker in the DevTools frames chart, you are not on the Impeller path — check the platform row above and whether something in your build is passing --no-enable-impeller or setting io.flutter.embedding.android.EnableImpeller to false in the manifest. Second, --trace-skia is a Skia flag. Under Impeller it will not give you the Skia draw-op breakdown that older blog posts show screenshots of; issue #114853 proposes a renderer-agnostic --trace-graphics to replace it. Impeller still emits raster-thread timeline events, so the Timeline events tab is useful — the flag just is not the lever it used to be.

A frame is a pipeline, and jank is a question about which stage ran long

“16ms” is a budget for the whole pipeline, not for one function. At 60Hz you have 16.67ms per frame; on a 120Hz display it is 8.33ms, which is why a bug report from someone with a high-refresh phone can describe stutter you cannot see on your own device.

Two threads split that budget, and the docs define them precisely. The UI thread “executes Dart code in the Dart VM… When your app creates and displays a scene, the UI thread creates a layer tree, a lightweight object containing device-agnostic painting commands, and sends the layer tree to the raster thread.” The raster thread “takes the layer tree and displays it by talking to the GPU.” Impeller runs on the raster thread. Note the docs’ own caveat: “while the raster thread rasterizes for the GPU, the thread itself runs on the CPU” — a slow raster thread is not automatically a GPU problem.

You can read those two numbers programmatically, which is worth wiring up before you open DevTools at all, because it tells you instantly whether you are hunting a Dart problem or a painting problem:

import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  SchedulerBinding.instance.addTimingsCallback((List<FrameTiming> timings) {
    for (final t in timings) {
      if (t.totalSpan > const Duration(milliseconds: 16)) {
        debugPrint('frame ${t.frameNumber} '
            'build=${t.buildDuration.inMicroseconds}us '
            'raster=${t.rasterDuration.inMicroseconds}us '
            'total=${t.totalSpan.inMicroseconds}us');
      }
    }
  });
  runApp(const MyApp());
}

buildDuration is “the duration to build the frame on the UI thread”; rasterDuration is “the duration to rasterize the frame on the raster thread”; totalSpan is the whole span from vsync to raster finish. FrameTiming also exposes layerCacheBytes and pictureCacheBytes, which matter later when you get to images.

Profile mode, on a real device, before you believe any number

Debug-mode timings are meaningless for this. Debug builds run Dart in the VM’s JIT with assertions on and no optimization, and the framework itself does extra work — debugPrint of paint bounds, tree integrity checks, the lot. A widget that takes 9ms in debug can take under 1ms in profile.

flutter devices
flutter run --profile -d <device-id>

Profile mode is not available on simulators or emulators for the purpose you care about — you want the actual GPU and the actual thermal behaviour of the device the bug was filed against. Then, from the running terminal, press P to toggle the performance overlay, or open the DevTools URL that flutter run prints and go to the Performance view.

Read the overlay the way the docs describe it: two graphs of the last 300 frames, white lines at 16ms increments, and red bars where a frame blew the budget. Red on the UI graph means your Dart is too expensive. Red on the raster graph means the scene is too complex to draw. That is a two-second triage and it is usually enough to pick which of the next three sections you read. One other flag is still worth knowing: --trace-systrace routes timeline events into the platform’s own tracer (systrace on Android, os_signpost on iOS), which is what you want when you suspect the jank is happening outside the Dart side entirely — a plugin doing blocking work on the platform thread, for instance.

Turn on the tracing that names the widget

The frames chart tells you a frame was slow. The Frame analysis tab tells you why, in words: select a janky frame and DevTools shows hints for expensive operations it detected in that frame. Start there — it is free and often correct.

When it is not enough, use Enhance tracing, the dropdown in the Performance view. It has three toggles:

  • Track widget builds — every build() becomes a timeline event labelled with the widget’s name.
  • Track layouts — every render object layout becomes an event.
  • Track paints — every render object paint becomes an event.

With these on, the Timeline events tab stops saying “Build” and starts saying “ProductCard.build” nested under “ProductList.build”, with durations. That is the moment the investigation becomes trivial.

Two warnings. These toggles add real instrumentation overhead, so the absolute numbers get worse while they are on — use them to find the name, then turn them off and re-measure to confirm the fix. And they are per-frame in scope; for a stretch of your own code that spans frames, instrument it yourself:

import 'dart:developer' as developer;

developer.Timeline.timeSync('decodePriceHistory', () {
  history = PriceHistory.fromJson(payload);
});

That event appears on the UI thread in the Timeline events tab with its own bar, next to the framework’s.

UI-thread jank: builds that do too much, layouts that measure twice

Once Track widget builds points at a widget, there are only a few shapes this takes.

A build that does work. Parsing, sorting, formatting, regex, DateFormat construction — anything that is not “describe the UI” — inside build(). It runs on every rebuild, which during an animation is every frame. The fix is not clever: hoist it into initState, a memoised field, or a compute() isolate call, and let build() only read the result.

A rebuild scope that is too wide. A setState at the top of a page rebuilds the page. If the thing that changed is a counter in one corner, wrap only that corner. AnimatedBuilder and AnimatedWidget take a child that is built once and passed through the builder untouched:

AnimatedBuilder(
  animation: _controller,
  child: const ExpensiveProductCard(),   // built once, not per frame
  builder: (context, child) => Transform.rotate(
    angle: _controller.value * math.pi,
    child: child,
  ),
)

const constructors do the same job statically: a const widget is canonicalised, so the framework can skip its subtree during rebuild rather than diffing it.

A layout that measures the tree twice. IntrinsicHeight and IntrinsicWidth ask every child for its intrinsic size before they can lay anything out — a second walk of the subtree, per frame. The framework’s own API docs call the intrinsic-sizing widgets relatively expensive and advise avoiding them where possible. If you are using IntrinsicHeight to make two cards in a Row the same height, a SizedBox with a known height, or CrossAxisAlignment.stretch inside a bounded parent, does the job with one pass.

Long lists have a matching fix. A ListView.builder whose items are all the same height should say so, because then the scroll machinery can compute offsets arithmetically instead of laying out children to find out where they are:

ListView.builder(
  itemExtent: 72,
  itemCount: items.length,
  itemBuilder: (context, i) => RowTile(item: items[i]),
)

Use prototypeItem if the extent is uniform but you would rather not hardcode it. And shrinkWrap: true on a list inside another scrollable is the single most common self-inflicted layout cost in Flutter — it forces the list to lay out all of its children to measure itself, which defeats the entire point of .builder.

Raster-thread jank: saveLayer is still the most expensive call in the framework

If the red is on the raster graph, the layer tree is expensive to draw. The dominant cause, on Impeller as on Skia, is an offscreen render pass: the engine draws a subtree into a separate texture, then composites that texture back. That costs an extra pass and a lot of memory bandwidth, and it is exactly what Canvas.saveLayer does.

You rarely call it. The docs are explicit that “even if you don’t call saveLayer explicitly, implicit calls might happen on your behalf, for example when specifying Clip.antiAliasWithSaveLayer (typically as a clipBehavior).” Opacity over a group, ShaderMask, BackdropFilter, and ColorFiltered are the other usual suspects.

DevTools gives you a bisection tool for this that most people never touch. Under More debugging options there are toggles to disable whole classes of layer: Render Clip layers, Render Opacity layers, Render Physical Shape layers. Turn one off, re-record, and look at the raster bar. If turning off opacity layers makes the jank vanish, you have your answer in about thirty seconds, without reading a single timeline event.

The fixes are all the same idea — fold the effect into the paint instead of wrapping a subtree in one.

// Offscreen pass: the subtree is drawn to a texture, then composited.
Opacity(opacity: 0.4, child: Image.asset('assets/hero.jpg'))

// No offscreen pass: alpha is folded into the image's own paint.
Image.asset('assets/hero.jpg', opacity: _fade)   // Animation<double>
// A clip layer, plus a full offscreen pass because of the clipBehavior.
ClipRRect(
  borderRadius: BorderRadius.circular(16),
  clipBehavior: Clip.antiAliasWithSaveLayer,
  child: ColoredBox(color: Colors.indigo, child: child),
)

// The same rounded rectangle, drawn directly. No clip, no layer.
DecoratedBox(
  decoration: BoxDecoration(
    color: Colors.indigo,
    borderRadius: BorderRadius.circular(16),
  ),
  child: child,
)

The general rule the docs give is to apply opacity, clipping and shadows to individual widgets rather than to a group higher in the tree, and to ask whether the effect is needed at all. Clip.antiAlias — the default clipBehavior for most widgets — is far cheaper than Clip.antiAliasWithSaveLayer; if someone changed it to the WithSaveLayer variant to fix a hairline seam, check whether the seam is still there.

RepaintBoundary is the other tool here: it isolates a subtree so that repainting it does not repaint its neighbours, and it lets the raster cache keep the result. But it costs GPU memory per boundary. The docs’ framing is right — cache “only where absolutely necessary.” A RepaintBoundary around every list item is a memory bug wearing a performance-fix costume.

Images decoded at the wrong size

The last common cause is an image whose decoded size has nothing to do with its displayed size. Flutter decodes images off the UI thread, so this rarely shows up as a tall UI bar; it shows up as a one-frame spike when the image first appears, as memory pressure that causes GC pauses later, and as a raster thread sampling a texture far larger than the box it is drawing into.

The fix is to decode at display size. Both forms are one line:

// Decode at 320 logical pixels wide instead of whatever the server sent.
Image.network(url, cacheWidth: 320)

// Same thing when you are holding an ImageProvider.
ResizeImage(NetworkImage(url), width: 320)

cacheWidth and cacheHeight are int? and, per the docs, “indicate to the engine that the image should be decoded at the specified size,” primarily to reduce ImageCache memory. Note they are disregarded on web, where decoding is delegated to the browser.

For a hero image you know is coming — the next page in a flow, the first card in a carousel — move the decode out of the frame that needs it:

@override
void didChangeDependencies() {
  super.didChangeDependencies();
  precacheImage(const AssetImage('assets/hero.jpg'), context);
}

If you suspect the image cache is the problem rather than any individual image, FrameTiming.layerCacheBytes and pictureCacheBytes from the callback earlier will show you the cache growing frame over frame, and PaintingBinding.instance.imageCache.maximumSizeBytes lets you cap it.

FAQ

Impeller is the default now — do I still need to profile at all?

Yes. Impeller removed the shader-compilation class of jank, which was the one you could not fix from Dart. Expensive builds, extra layout passes, saveLayer, and oversized image decodes are all still yours to fix, and they are all visible in the same DevTools view they were visible in five years ago.

Why does --trace-skia not show me anything useful anymore?

Because it is a Skia flag and your app is drawing with Impeller. Impeller still emits timeline events on the raster thread, so the Timeline events tab works; you just do not get the Skia draw-op breakdown that flag was built for. There is an open proposal, flutter/flutter#114853, for a renderer-agnostic --trace-graphics flag.

The jank only happens on one specific Android phone. Where do I start?

Check whether that device is getting the Vulkan path. The docs say Impeller falls back to the legacy OpenGL renderer on Android versions below API 29 or on devices without Vulkan, and the fallback path does not have the same characteristics. Reproduce with flutter run --profile on that exact device, and include the chip in any issue you file — the Impeller docs explicitly ask for device and chip details.

Should I put RepaintBoundary everywhere to be safe?

No. Each boundary is a separate layer with its own GPU memory cost, and the raster cache entries the docs describe as “expensive to construct.” Add one where a small subtree animates independently of a large static one — a spinner over a complex page — and measure the raster bar before and after. If the number does not move, take it back out.

Does any of this apply to Flutter web?

Partly. The UI-thread half does: builds, layouts, and rebuild scope behave the same. The rendering half does not — web still renders with Skia rather than Impeller, cacheWidth/cacheHeight are ignored because decoding is delegated to the browser, and DevTools cannot attach in profile mode on web, so you use Chrome DevTools instead.


Everything about renderer defaults, flags, thread definitions and API behaviour above is drawn from the official Flutter docs linked in the sources; the diagnostic order — measure, split by thread, then name the widget — is my own working habit, not a documented procedure. Renderer defaults and flag availability move between releases, so check docs.flutter.dev/perf/impeller against the version in your pubspec.lock before concluding that something is or is not enabled for you.


Originally published on FlutterCook. Read the latest version there — that copy is the one kept up to date.

Nhận xét

Bài đăng phổ biến từ blog này

Thiết kế giao diện với DotNetBar (Phần 1)

Đây là phiên bản DotNetBar hỗ trợ C# và Visual Basic https://www.dropbox.com/s/wx80jpvgnlrmtux/DotNetBar.rar  , phiên bản này hỗ trợ giao diện Metro cực kỳ “dễ thương” Các bạn load về và cài đặt, khi cài đặt xong sẽ có source code mẫu của tất cả các control. Để sử dụng được các control của DotNetBar các bạn nhớ add item vào controls box. Thiết kế giao diện với DotNetBar, giao diện sẽ rất đẹp. Link các video hướng dẫn chi tiết cách sử dụng và coding: http://www.devcomponents.com/dotnetbar/movies.aspx Hiện tại DotNetBar có rất nhiều công cụ cực mạnh, trong đó có 3 công cụ dưới đây: DotNetBar for Windows Forms Requires with Visual Studio 2003, 2005, 2008, 2010 or 2012.   DotNetBar for WPF Requires with Visual Studio 2010 or 2012 and Windows Presentation Foundation.   DotNetBar for Silverlight Requires with Visual Studio 2010 or 2012 and Silverlight. Dưới đây là một số hình ảnh về các control trong DotnetBar.   Metro User Interface  controls with Metro Tiles, toolba...

5 concepts every Flutter dev should know

  Phụ lục: State management architecture Testing IDE Shortcuts Platform channel Maintaining a project Tôi đã làm việc với Flagship trong một thời gian dài, và đây là những điều mà tôi phát hiện ra là điều cần phải có đối với bất kỳ nhà phát triển Flagship nào, về tổng thể nó sẽ khiến bạn trở thành một nhà phát triển Flagship giỏi trong thời gian dài. 1. State management architecture Đây là một trong những chủ đề quan trọng nhất trong cộng đồng thiết bị rung, nó khá quan trọng nếu bạn muốn duy trì một dự án rung kích thước trung bình hoặc lớn. Nó sẽ giúp tạo một dự án suôn sẻ và thêm các tính năng mới một cách hoàn hảo.  2. Testing Đây là một chủ đề duy nhất mà tôi không hiểu tại sao nó lại quan trọng trước đó trong sự nghiệp của tôi, nhưng khi tôi tiến lên trong sự nghiệp của mình và có kinh nghiệm với nhiều dự án và vấn đề xảy ra trong môi trường sản xuất. Tôi đã nhận ra một cách khó khăn, tại sao điều này lại quan trọng như vậy. Nếu bạn vẫn muốn có thêm lý do để cân nhắc thử...

Announcing Flutter 2

  Phụ lục: Flutter on the web Flutter 2 on desktops, foldables, and embedded devices The growing Flutter ecosystem Dart: The secret sauce behind Flutter Flutter 2: Available now Hôm nay, chúng tôi sẽ công bố Flutter 2: một bản nâng cấp lớn cho Flutter cho phép các nhà phát triển tạo các ứng dụng đẹp, nhanh chóng và di động cho bất kỳ nền tảng nào. Với Flutter 2, bạn có thể sử dụng cùng một cơ sở mã để gửi các ứng dụng gốc cho năm hệ điều hành: IOS, Android, Windows, macOS và Linux; cũng như trải nghiệm web nhắm mục tiêu các trình duyệt như Chrome, Firefox, Safari hoặc Edge. Flutter thậm chí có thể được nhúng vào ô tô, TV và thiết bị gia dụng thông minh, mang đến trải nghiệm di động và lan tỏa nhất cho thế giới điện toán xung quanh. Mục tiêu của chúng tôi là thay đổi cơ bản cách các nhà phát triển nghĩ về việc xây dựng ứng dụng, bắt đầu không phải với nền tảng bạn đang nhắm mục tiêu mà là với trải nghiệm bạn muốn tạo. Flutter cho phép bạn tạo ra những trải nghiệm tuyệt đẹp trong đó ...