Memory Optimization and Leak Detection in Flutter iOS Apps: Image Component, PlatformView, IOSurface and Tooling
This article analyzes the high memory consumption of Flutter's Image widget, PlatformView leaks, and IOSurface residues on iOS, then presents concrete solutions such as external texture rendering, empty‑page redraw, engine reset, and a custom Expando‑based memory‑leak detector built with Dart VM Service.
1 Background
Soul App uses a mixed technology stack that includes native code, Web containers and Flutter. While Flutter improves development efficiency, we observed increasing memory usage, especially on iOS with Flutter 3.0.5, leading to low‑memory stability issues.
This article shares how we tackled memory problems on iOS by focusing on three areas: the Flutter image component, memory cleanup, and detection tools.
2 Memory Issues and Solutions
2.1 System Image Component Consumes Excessive Memory
2.1.1 Problem Background
The built‑in Image widget has several inherent drawbacks: it cannot access native resources, lacks local caching, has an unconfigurable network download method, and cannot reuse native image‑download libraries. Even when cacheWidth and cacheHeight are set, memory usage remains high. A test page loading network images with the system component added more than 300 MB of memory.
2.1.2 Solution: External Texture
We evaluated two community approaches: extending the official Image component with extra cache, and reusing native image libraries via an external‑texture solution. We chose the external‑texture method for better extensibility and reuse of native cache management.
2.1.2.1 Implementation Process
Texture represents a GPU‑side image object. Flutter provides a Texture widget to draw it. On iOS the flow is:
@override
Future
dispose() async {
if (_initialized) {
await SystemChannels.platform_views.invokeMethod
('dispose', viewId);
}
}The native side creates an object implementing FlutterTexture , registers it with FlutterTextureRegistry to obtain a texture ID, and passes that ID to Flutter via a channel. The Flutter side then renders the texture. By using reference counting, the same image can share a single texture ID, reducing memory churn.
2.1.2.2 Core Benefits
The external‑texture image component dramatically lowers memory peaks; a comparable page dropped from 641 MB to 520 MB after exit.
2.2 PlatformView Memory Leak
2.2.1 Problem Background
Memory graphs showed a persistent 100 MB after leaving a page, and the second entry to the page used less memory than the first. Investigation revealed that FlutterPlatformViewsController retained the PlatformView objects.
void FlutterPlatformViewsController::OnDispose(FlutterMethodCall* call, FlutterResult& result) {
NSNumber* arg = [call arguments];
int64_t viewId = [arg longLongValue];
if (views_.count(viewId) == 0) {
result([FlutterError errorWithCode:@"unknown_view" message:@"trying to dispose an unknown" details:[NSString stringWithFormat:@"view id: '%lld'", viewId]]);
return;
}
// We wait for next submitFrame to dispose views.
views_to_dispose_.insert(viewId);
result(nil);
}The dispose call is invoked on the Flutter side, but the native side never deallocates the view because the engine runs in single‑engine mode (flutter_boost) and the Flutter page is removed without a redraw, so submitFrame is never triggered.
2.2.2 Solution: Empty‑Page Redraw
After popping a Flutter page we present a blank Flutter page to force a frame render, which triggers the pending disposal. The implementation (iOS) is:
SOFlutterFirstViewController* vc = SOFlutterFirstViewController.new;
vc.view.backgroundColor = UIColor.clearColor;
UIWindow* lastWindow = [UIApplication sharedApplication].keyWindow;
[lastWindow.rootViewController presentViewController:vc animated:NO completion:^{
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[vc dismissViewControllerAnimated:NO completion:nil];
});
}];2.3 IOSurface Memory Residue
2.3.1 Problem Background
When using PlatformView for RTC video rendering, large IOSurface objects (≈230 MB) remained after page exit, as shown by vmmap -summary snapshots.
2.3.2 Solution: Engine Reset
We destroy and recreate the Flutter engine, which clears all native resources. To minimise impact we cache plugin registrations, delay the reset, and ensure the reset only occurs when no Flutter pages remain in the container.
- (void)didMoveToParentViewController:(nullable UIViewController *)parent;
- (void)dismissViewControllerAnimated:(BOOL)flag completion:(void (^ __nullable)(void))completion;We also provide a helper to pop a page, wait, then enable engine reset after the next navigation:
void commonRouteAfterPop({
String nativePath = "",
String flutterPageId = "",
Map
? params,
int delayMilliseconds = 500,
bool withContainer = false}) {
EngineUtil.shouldResetEngine(reset: false);
pop();
Future.delayed(Duration(milliseconds: delayMilliseconds), () {
SoulNavigator route = commonRoute(nativePath: nativePath, flutterPageId: flutterPageId);
route.navigate(withContainer: withContainer);
Future.delayed(Duration(milliseconds: 1000), () {
EngineUtil.shouldResetEngine(reset: true);
});
});
}3 Memory Detection Tools
Engine reset solves many leaks but does not pinpoint the source. We therefore built a custom detection tool based on Dart's Expando (a weak‑reference map) and the VM Service API.
3.1 Expando
Expando works like a weak hash map: the key does not increase the reference count, allowing the GC to collect the object while the value remains accessible via the weak property.
@patch
T? operator [](Object object) {
checkValidWeakTarget(object, 'object');
var mask = _size - 1;
var idx = object._identityHashCode & mask;
var wp = _data[idx];
while (wp != null) {
if (identical(wp.key, object)) {
return unsafeCast
(wp.value);
} else if (wp.key == null) {
_data[idx] = _deletedEntry;
}
idx = (idx + 1) & mask;
wp = _data[idx];
}
return null;
}3.2 VM Service
The VM Service provides debugging information such as ObjectId , ObjRef and Obj . We use it to retrieve the weak‑reference objects stored in an Expando and then walk the retaining path to the GC roots.
Future
findMainIsolate() async {
final vm = await getVM();
if (vm == null) return null;
IsolateRef? ref;
vm.isolates?.forEach((isolate) {
if (isolate.name == 'main') ref = isolate;
});
final vms = await getVmService();
if (ref?.id != null) return vms?.getIsolate(ref!.id!);
return null;
}We generate a unique key, store the target object in a temporary cache, then retrieve its ObjectId via keyToObj and finally call getObject and getRetainingPath to obtain the leak chain.
int _key = 0;
String generateKey() => "${++_key}";
Map
_objCache = {};
dynamic keyToObj(String key) => _objCache[key];
Response keyResponse = await vms.invoke(mainIsolate.id!, library.id!, 'generateKey', []);
final keyRef = InstanceRef.parse(keyResponse.json);
String? key = keyRef?.valueAsString;
if (key != null) _objCache[key] = obj;
Response valueResponse = await vms.invoke(mainIsolate.id!, library.id!, 'keyToObj', [keyRef!.id!]);
final valueRef = InstanceRef.parse(valueResponse.json);
return valueRef?.id;3.3 Detection Timing
We start detection when a page is popped. After a manual GC via the VM Service, we query the retained objects. For apps using flutter_boost we listen to PageVisibilityBinding callbacks to know when a page becomes invisible.
4 Future Plans
Improve the image component: add cancelable downloads and off‑screen disposal.
Integrate the detection tool into CI/testing pipelines.
Add online Dart memory‑leak monitoring for production builds.
Thank you for reading; please follow, comment, or like if you found this useful.
Soul Technical Team
Technical practice sharing from Soul
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.