🚿

Dart の firebase_storage のアップロード処理のメモリリークを解決した

2024/11/16に公開

概要

Flutter で iOS アプリを開発しており, その機能の一つとして 5-15MB の PNG 画像を Firebase Storage にアップロードするというものがありました.
アップロードするたびに 2-5MB のメモリリークが発生し、大量の画像をアップロードすると最後にはメモリ使用過多でクラッシュするという事象が発生しました.

メモリリークが発生する条件が特定できなかったため, GitHub の Issue は作成していません.
本記事ではそのときの状況のまとめと, 解決法についてのみ述べます.

メモリリークが発生している処理

Future<TaskSnapshot> uploadBytes({
    required String path,
    required Uint8List bytes,
    required String contentType,
}) async {
    final ref = FirebaseStorage.instance.ref(path);
    return ref.putData(bytes, SettableMetadata(contentType: contentType));
}

この処理を実行するだけのボタンを押してもメモリリークは発生しませんでした.
他に実装していた一連の処理との兼ね合いでメモリリークが発生しているようですが, 複雑でメモリリークの発生条件の特定には至りませんでした.

関連するスタックトレース

XCode の Instruments で解放されずに残り続けているオブジェクトのスタックトレースを取得しました.

スタックトレース
fml::MallocMapping::Copy(void const*, unsigned long)	
flutter::(anonymous namespace)::HandlePlatformMessage(flutter::UIDartState*, std::_fl::basic_string<char, std::_fl::char_traits<char>, std::_fl::allocator<char>> const&, _Dart_Handle*, fml::RefPtr<flutter::PlatformMessageResponse> const&)	
tonic::FfiDispatcher<void, _Dart_Handle* (*)(std::_fl::basic_string<char, std::_fl::char_traits<char>, std::_fl::allocator<char>> const&, _Dart_Handle*, _Dart_Handle*), &flutter::PlatformConfigurationNativeApi::SendPlatformMessage(std::_fl::basic_string<char, std::_fl::char_traits<char>, std::_fl::allocator<char>> const&, _Dart_Handle*, _Dart_Handle*)>::Call(_Dart_Handle*, _Dart_Handle*, _Dart_Handle*)	
stub CallNativeThroughSafepoint	
PlatformDispatcher.___sendPlatformMessage$Method$FfiNative	
PlatformDispatcher.__sendPlatformMessage	
PlatformDispatcher._sendPlatformMessage	
PlatformDispatcher.sendPlatformMessage	
DefaultBinaryMessenger.send	
BasicMessageChannel.send	
FirebaseStorageHostApi.referencePutData	
MethodChannelPutTask._getTask	
new MethodChannelPutTask	
MethodChannelReference.putData	
Reference.putData	
FirebaseStorageHelper.uploadBytes	
InferenceRepository._uploadImages	
InferenceRepository._saveLog	
InferenceRepository.infer	
SuspendState._createAsyncCallbacks.thenCallback	
rootRunUnary	
rootRunUnary (#2)	
_CustomZone.runUnary	
Future._propagateToListeners.handleValueCallback	
Future._propagateToListeners	
Future._completeWithValue	
SuspendState._returnAsyncNotFuture	
stub _iso_stub_ReturnAsyncNotFutureStub	
SuspendState._createAsyncCallbacks.thenCallback	
rootRunUnary	
rootRunUnary (#2)	
_CustomZone.runUnary	
SuspendState._awaitCompletedFuture.run	
rootRun	
rootRun (#2)	
CustomZone.run	
CustomZone.runGuarded	
CustomZone.bindCallbackGuarded.<anonymous closure>	
microtaskLoop	
startMicrotaskLoop (#2)	
startMicrotaskLoop	
stub InvokeDartCode	
dart::DartEntry::InvokeFunction(dart::Function const&, dart::Array const&, dart::Array const&)	
Dart_InvokeClosure	
tonic::DartMicrotaskQueue::RunMicrotasks()	
fml::MessageLoopImpl::FlushTasks(fml::FlushType)	
fml::MessageLoopDarwin::OnTimerFire(__CFRunLoopTimer*, fml::MessageLoopDarwin*)	
__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__	
__CFRunLoopDoTimer	
__CFRunLoopDoTimers	
__CFRunLoopRun	
CFRunLoopRunSpecific	
fml::MessageLoopDarwin::Run()	
std::_fl::__function::__func<fml::Thread::Thread(std::_fl::function<void (fml::Thread::ThreadConfig const&)> const&, fml::Thread::ThreadConfig const&)::$_0, std::_fl::allocator<fml::Thread::Thread(std::_fl::function<void (fml::Thread::ThreadConfig const&)> const&, fml::Thread::ThreadConfig const&)::$_0>, void ()>::operator()()	
fml::ThreadHandle::ThreadHandle(std::_fl::function<void ()>&&)::$_0::__invoke(void*)	
_pthread_start	
thread_start	

解決法

コード

アップロード処理を Isolate に切り出すことでメモリリークが発生しなくなりました.

Future<TaskSnapshot> _isolatedUpload(
  (String, Uint8List, String) params,
) async {
  final (path, bytes, contentType) = params;
  final ref = FirebaseStorage.instance.ref(path);
  return ref.putData(bytes, SettableMetadata(contentType: contentType));
}

Future<TaskSnapshot> uploadBytes({
  required String path,
  required Uint8List bytes,
  required String contentType,
}) =>
    Isolate.run<TaskSnapshot>(
      () => _isolatedUpload((path, bytes, contentType)),
    );

解決の仕組み

以下の一連の流れの中で, メイン Isolate での実行時、ネイティブブリッジを介したメモリが適切に解放されない事象が発生していました.

メイン Isolate
↓
Flutter Platform Channel
↓
iOS Native Bridge
↓
Firebase SDK (iOS)

別の Isolate に切り出すことで, これが次のように変わります.

メイン Isolate
    ↓
新しい Isolate(独立したメモリ空間)
    ↓
Flutter Platform Channel
    ↓
iOS Native Bridge
    ↓
Firebase SDK (iOS)
    ↓
Isolate 終了時に全メモリを解放
  • Isolate は完全に独立したメモリ空間を持つ
  • Isolate 終了時にその Isolate で使用された全てのリソース(ネイティブブリッジ含む)が強制的に解放される
  • メイン Isolate には影響を与えない

これらの性質を使って, メイン Isolate で解放されないメモリを別の Isolate 内で確保することによってうまく解放してやることに成功しています.

環境

検証デバイス

以下のいずれのデバイスでも再現しました.

  • iPhone 12 Pro (iOS 18.1)
  • iPhone SE 第三世代 (iOS 18.1)
  • iPhone 8 (iOS 16.7.10)

バージョン

$ fvm flutter --version
Flutter 3.24.1 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 5874a72aa4 (3 months ago) • 2024-08-20 16:46:00 -0500
Engine • revision c9b9d5780d
Tools • Dart 3.5.1 • DevTools 2.37.2

XCode: Version 16.1 (16B40)

firebase_core: 3.6.0
firebase_storage: 12.3.4

Discussion