本記事は、Kotlin Advent Calendar 2025 の 17 日目の記事です。
はじめに
Kotlin Multiplatform(KMP)で iOS アプリを開発する際、Swift と Kotlin の相互運用は避けて通れません。KMP は非常に便利ですが、トラブルなく使いこなすには Swift と Kotlin/Native 両方のメモリ管理の特性をある程度理解している必要があります。
ここでは、両者のメモリ管理の違いを簡単に紹介した後、この違いを理解していないと、Swift/iOS 側のライフサイクル管理において直感に反するように見える挙動について解説します。
具体的には「Swift から Kotlin の関数等にインスタンスやその参照を渡すと、Swift 側の解放が遅延する」という事象です。
なお、本記事では Swift を例に説明しますが、Objective-C から Kotlin の実装を呼び出す場合でも同様の挙動となります。
インスタンスの解放が遅れる(deinit が遅れる)
例えば SwiftUI では、画面を閉じたタイミングで、その画面に関連するインスタンスが解放されると期待されがちです。Swift エンジニアにとっては、そのような挙動が自然に感じられる場面も多いでしょう。
しかし、Kotlin への関数呼び出しなどでインスタンスを渡している場合、画面を閉じてもすぐにはインスタンスが解放されない場合があります。つまり、deinit が呼ばれるタイミングが遅れます。少し時間が経過した後や、他の処理が進んだ後に、ようやくインスタンスが解放されるという挙動になります。
この現象を理解するには、Swift の ARC と Kotlin/Native の GC という、メモリ管理方式の違いを知る必要があります。
問題が発生する例
まずは、問題が発生するコードを見てみましょう。
以下の実装の場合、callKmpFunction() を実行した直後に画面を閉じても、Foo の deinit が呼ばれるまでに遅延が発生します。
fun function(obj: Any) {
// ...
}
class Foo {
func callKmpFunction() {
FunctionKt.function(obj: self)
}
deinit {
print("deinit")
}
}
以下のようにクロージャ(ラムダ)の形で Kotlin 側に渡し、その中で self を参照している場合も同様です。
fun function(provider: () -> String) {
// ...
}
class Foo {
func callKmpFunction() {
FunctionKt.function { String(describing: self) }
}
deinit {
print("deinit")
}
}
動作確認に利用した SwiftUI のコード
struct ContentView: View {
@State private var isPresented = false
var body: some View {
Button("Show") {
isPresented = true
}
.fullScreenCover(isPresented: $isPresented) {
FooView(
hide: {
isPresented = false
}
)
}
}
}
struct FooView: View {
var hide: () -> Void
@State private var foo = Foo()
var body: some View {
Button("Hide") {
foo.callKmpFunction()
hide()
}
}
}
Swift オブジェクトの生存期間が Kotlin の GC に依存する
Swift だけで完結する場合、インスタンスが参照されなくなった瞬間に解放されます。これは ARC(Automatic Reference Counting) という仕組みによるものです。ARC はインスタンスが何箇所から参照されているかをカウントし、参照されなくなった瞬間(カウントが 0 になったとき)にインスタンスを解放します。
一方、Kotlin/Native では GC(Tracing Garbage Collector) という仕組みが使われています1。GC は、参照されなくなったオブジェクトを定期的に検出し、まとめて解放するアプローチをとります。
Swift の ARC は当然、Kotlin 側からの参照もカウントします。Kotlin 側にインスタンス(またはそれを参照したクロージャ)を渡すと、Kotlin/Native から Swift のインスタンスを参照している状態になります。
このとき、たとえ Kotlin 側の関数の実行が終了しても、Kotlin 側の GC が走り、そのラッパーオブジェクトが破棄されるまでは、Swift 側のインスタンスへの参照(参照カウント)が残り続けます。
結果として、Swift 側では ARC による参照カウントが 0 にならず、deinit の呼び出しが Kotlin/Native の GC が実行されるタイミングまで遅れることになります。
予期せぬ不具合の可能性
この挙動は、単に「メモリの解放が少し遅れる」という性能面の話だけではなく、実害のある不具合につながる可能性があります。これは言語やフレームワークの欠陥ではなく、特性を理解していないことによる設計上のミスマッチが原因です。
例えば、deinit で「画面が閉じられたこと」を検出していたとします。そこで「アナリティクスイベントの送信」や「ユーザーへの何らかの通知」といった処理をする場合、deinit が遅れることで「閉じたはずなのにイベントが送られない」や「通知が来ない」といった挙動を引き起こします。
また、LoggerKt.log { String(describing: self) } のようなロガーを利用している場合も注意が必要です。「バグ調査のためにログを追加したら、self の寿命が延びてしまい、動作が変わってしまった」という事態にもなり得ます。
deinit を純粋なメモリ解放のためだけに利用している場合は大きな問題になりませんが、それ以外の副作用(イベント通知など)を期待している場合には特に注意が必要です。
対策
不要な参照を渡さない(Swift のライフサイクルを Kotlin に委ねない)
この問題を回避する最も確実な方法は、Kotlin 側に Swift のインスタンスを渡さないことです。
以下の実装例では、クロージャの中で self を参照するのではなく、Swift 側であらかじめ評価し、評価後の値を渡すように修正しています。
これであれば、Kotlin 側が受け取るのは評価結果であり、Foo インスタンスへの参照は含まれません。そのため、画面を閉じると同時に Foo のインスタンスが解放されます。deinit の呼び出しも遅れません。
func callKmpFunction() {
// クロージャ作成前に self を評価する
// これにより Kotlin 側から self への参照がなくなる
let str = String(describing: self)
FunctionKt.function { str }
// または、評価結果を直接渡すように関数を定義してもよい
// FunctionKt.function(str: String(describing: self))
}
弱参照を利用する
[weak self] を使用して強参照を防ぐ方法もあります。これにより、Kotlin 側が self を強参照しなくなるため、カウントが増えることはありません。
ただしこの方法では、Kotlin 側からクロージャが実行されるタイミングによっては self がすでに解放されている可能性があります。そのため、self が nil になることを考慮した実装が必要です。
FunctionKt.function { [weak self] in
String(describing: self)
}
まとめ
- Swift と Kotlin/Native ではメモリ管理方式が異なり、Swift は ARC、Kotlin/Native は GC を採用している
- Swift のインスタンスを Kotlin 側に渡すと、そのインスタンスの寿命は Kotlin の GC に依存するようになる
- インスタンスの解放が遅れることは、
deinitの呼び出しが遅れることを意味し、予期せぬ不具合につながる可能性がある - インスタンスそのものではなく、必要最低限のデータ(値型など)だけを Kotlin 側に渡すことや、弱参照を利用することで、この問題を回避できる
参考文献
- Kotlin/Native memory management - Kotlin Documentation
- Integration with Swift/Objective-C ARC - Kotlin Documentation
-
Kotlin/Native 1.7.20 から、新しいメモリ管理方式(New Memory Manager)がデフォルトとなり GC に移行しました。それ以前は参照カウントベースの管理方式が採用されていました。 ↩