🚿
Dart の firebase_storage のアップロード処理のメモリリークを解決した
概要
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