Xamarin.Forms アプリのリークパターンについて考える

de:code が終わってからずっと Xamarin.Forms アプリでメモリリークするパターンについて考えています。

考えを吐き出すために、図が少ないですがだらだら書きます。「Xamarin.Forms の」と第を付けておきながら、それに限らない話な気もします。

Xamarin.Forms アプリを作ると、現在では一般的には下図のようになると思います。

image.png

私が作ったサンプル

もこうなっています。

ここで、図の箱と箱をつなぐ先の ◆ は保持(UML クラス図でいう Composition のような)関係を表しています。たとえば、Android.app.ApplicationMainActivity を保持しています。

Xamarin.Forms アプリは、他の多くのクロスプラットフォームアプリと同様に、「Single Activity Application」であり、ただ一つのActivityを持ちます。

その中に "Xamarin.Forms としての" Application があり、その下に Xamarin.Forms の世界での「画面」を示す xxxPage がぶら下がります。

MVVM パターンで実装すると、Pageごとに ViewModel が作られ、両者はデータバインディングで相互に関連します。「依存」関係としては、View→ViewModel ですが、ViewModelの変更を監視するためにViewの一部(コールバック関数など)をViewModelに登録することになるため、「参照」関係としては「双方」です。

iOS アプリなどの参照カウンタ方式を採用するプラットフォームでは、お互いに強い参照を持ってしまうと、循環参照となりどちらのオブジェクトも自動破棄されずリークしてしまうので、弱参照(Weak Reference)を使ってこれを回避します。

Xamarin も採用するマーク・アンド・スイープ方式のガベージコレクタでは、循環参照していても、依存関係のより上位のオブジェクトが不要とマークされれば、ツリーの下位オブジェクトはすべて破棄対象になります。

例えば、Xamarin.Forms.Application と FirstPage を結ぶ線が断たれれば、FirstPageViewModel もゴミと判定され破棄対象になります。

しかし上図では、ViewModel は、ビジネスロジックが記述されるであろう Usecase クラスと循環参照でつながっています。

Usecase は Application の持ち物であるので、Application と Page の線を断っても、Usecase-ViewModel が循環参照したままでは、 Page も ViewModel も破棄されません。

Usecase クラスは、xxxPage/xxxPageViewModel よりも生存期間が長い場合が多いでしょう。

私は Xamarin.Forms.Application で Usecase クラスを生成して保持させる実装をよく行いますが、DIコンテナを使う場合も Usecase は xxxPage/xxxPageViewModel とは異なる生存期間を持つ、ことになります。

そして、Usecase を使用するのは xxxViewModel クラスになるでしょう。

単純に Usecase の同期/非同期メソッドを呼び出して結果を返値で得る、場合には問題になりませんが、「Usecase からの通知を待つ」場合には、Page-ViewModel の関係と同様に、循環参照になります。

例えば、Usecase が次のような機能を提供している場合です。


  • ネットワークのオンライン/オフラインを通知するイベントがある

  • Push 通知が届いたことを通知するイベントがある

イベントに限らず、Rx の IObservable<T> でも同様です。

ViewModel で Usecase からの通知を受信するには、何らかのレシーバーを Usecase に登録しないといけないので、相互参照になります。

なので、 ViewModel で Usecase からの通知を受信するための登録を行ったら、それは必ず解除してあげる必要があります

それはいつ・だれが行えばよいのでしょう?


ViewModel を Disposable にして Page.OnDisappearing で破棄するパターン

Android Architecture Component の ViewModel には、onCleard が用意されており、ここで解除処理をしてあげればよいのですが、Xamarin.Forms には、そのような用意されたものはありません。

問題は、登録処理は ViewModel のコンストラクタで行うであろうが、それと対になる破棄タイミングが無い、ことです。

public class SecondViewModel

{
public SecondViewModel(MyUsecase myUsecase)
{
myUsecase.OnlineChanged += ReceiveOnlineChanged; // いつ -= する?
}

void ReceiveOnlineChanged(object sender, bool online)
{
if (online) { DoOnlineWork(); }
}
}

ひとつのアイデアとしては Page.Disappearing のタイミングで破棄させる、というものがあります。

Page.Disappearing は、Page が破棄された(例: NavigationStackからPopされた)時に呼ばれるので、それを利用して次のように書けます。

public class SecondViewModel : IDisposable

{
public SecondViewModel(MyUsecase myUsecase)
{
myUsecase.OnlineChanged += ReceiveOnlineChanged;
}

void ReceiveOnlineChanged(object sender, bool online)
{
if (online) { DoOnlineWork(); }
}

void IDisposable.Dispose()
{
myUsecase.OnlineChanged -= ReceiveOnlineChanged;
}
}

public partial class SecondPage : ContentPage
{
public SecondPage(MyUsecase myUsecase)
{
InitializeComponent();
this.BindingContext = new SecondPageViewModel(myUsecase);
}

protected override void OnDisappearing()
{
(this.BindingContext as IDisposable)?.Dispose();
base.OnDisappearing();
}
}

これで大丈夫そう…ですが注意しなければならない事もあります。

本来 Disappearing は Appearing と対になるライフサイクルイベントです。

ViewModel の生成はコンストラクタで行っているのに、破棄は OnDisappearing で行うのは 「いびつ」 です。これの弊害は、例えば Page のインスタンスをキャッシュしておく場合に表れます。

public class FirstPage : ContentPage

{
private SecondPage secondPage;
async void OnButtonClick(object sender, EventArgs e)
{
if (this.secondPage == null)
{
this.secondPage = new SecondPage(this.myUsecase);
}

await this.Navigation.PushAsync(this.secondPage);
}
}

こんな実装自体好ましくありませんが、生成と破棄のタイミングが「いびつ」になっていると思わぬ不具合を引き起こすかもしれない事は要注意です。


メモリリークの見つけ方

Xamarin Profiler を使う手もあるのですが、アプリの動作が重くなるので、原始的な方法 「破棄されるべきオブジェクトのデストラクタでログ出力を仕掛けておき、GCを強制的に実行して確認する」 で行っています。


1. 対象クラス に Finalizer を定義してログを取る

public partial class SecondPage : ContentPage

{
public SecondPage(MyUsecase myUsecase)
{
System.Diagnostics.Debug.WriteLine("call SecondPage ctor.");

// 省略
}

~SecondPage()
{
System.Diagnostics.Debug.WriteLine("call SecondPage finalizer.");
}
}


2. 対象クラスのインスタンスが破棄されるべき箇所で GC.Collect() を実行する

対象クラスのインスタンスがもう不要になったと期待される箇所、例えば SecondPage ならば、FirstPage に戻ったとき(あるいは次回 SecondPage を表示したいとき)にブレークポイントで一時停止し、イミディエイトウィンドウで System.GC.Collect() を実行します(一度の GC では破棄されないかもしれないので何度か実行するとよいです GC.Collect() のあとで GC.WaitForPendingFinalizers() を実行すると「ゴミが回収されるまで待つ」ことができます1)。

期待通り破棄されていれば、以下のようにログに Finalizer が呼び出されたことが出力されます。

call SecondPage finalizer.

[Mono] GC_BRIDGE waiting for bridge processing to finish
[art] Starting a blocking GC Explicit
[art] Explicit concurrent mark sweep GC freed 9626(498KB) AllocSpace objects, 0(0B) LOS objects, 40% free, 2MB/3MB, paused 371us total 12.831ms
[Mono] GC_TAR_BRIDGE bridges 179 objects 209 opaque 6 colors 179 colors-bridged 179 colors-visible 179 xref 3 cache-hit 0 cache-semihit 0 cache-miss 0 setup 0.05ms tarjan 0.09ms scc-setup 0.05ms gather-xref 0.02ms xref-setup 0.01ms cleanup 0.12ms
[Mono] GC_BRIDGE: Complete, was running for 15.60ms
[Mono] GC_MAJOR: (user request) time 17.94ms, stw 20.85ms los size: 1024K in use: 145K
[Mono] GC_MAJOR_SWEEP: major size: 1968K in use: 791K

ログに出力されなければ、それがリークです。


Android アプリのライフサイクルとの関係

冒頭でも言ったように、Xamarin.Forms アプリは 「Single Activity App」 なので、アプリに必要なオブジェクトは、全て MainActivity が保持しています。

すなわち、MainActivity が破棄されれば、Xamarin.Forms の世界のオブジェクトはすべて破棄されるのが望ましい、という事です。

そして Android の世界において、MainActivity はまあまあ頻繁に破棄されます。他のアプリを前面に表示して利用し続けていると、裏側の Activity は OS によって勝手に破棄されることは、よく知られています。

これは、Android 端末の開発者オプション - ✅アクティビティを保持しない で容易に再現できます。

image.png

さて、MainActivity が破棄されたときに Xamarin.Forms の世界のオブジェクト、PageXamarin.Forms.Application は破棄されるのか、確認してみます。

新しい Xamarin.Forms アプリのソリューションを作成し、 App.xaml.cs, MainPage.xaml.cs、そして Android 側の MainActivity.cs のそれぞれのコンストラクタと Finalizer でログ出力します。

public partial class MainPage : ContentPage

{
public MainPage()
{
System.Diagnostics.Debug.WriteLine("call MainPage ctor.");
}

~MainPage()
{
System.Diagnostics.Debug.WriteLine("call MainPage finalizer.");
}
}

public partial class App : Application
{
public App()
{
System.Diagnostics.Debug.WriteLine("call App ctor.");
}

~App()
{
System.Diagnostics.Debug.WriteLine("call App finalizer.");
}
}

public class MainActivity : global::Xamarin.Forms.Platform.Android.FormsAppCompatActivity
{
protected override void OnCreate()
{
System.Diagnostics.Debug.WriteLine("call MainActivity OnCreate.");
}

protected override void OnDestroy()
{
base.OnDestroy();
System.Diagnostics.Debug.WriteLine("call MainActivity OnDestroy.");
}

~MainActivity()
{
System.Diagnostics.Debug.WriteLine("call MainActivity finalizer.");
}
}

これ変更を加えただけのアプリを、MainActivity.csOnDestroyDebug.WriteLine の行にブレークポイントを設定した状態で、事前に「アクティビティを保持しない」設定を有効にした端末やエミュレータでデバッグ実行します。

ホームボタンを押して、アプリをバックグラウンドに退避させると、ブレークポイントで停止するので、そこでイミディエイトウィンドウから GC.Collect() を何度か実行します。

何度 GC.Collect() を行っても、App や MainPage の Finalizer が呼び出されていないことに注目です。ここでこれらが破棄されることを期待していたのですが違ったようです。

デバッグを続行し、背面に退避したアプリを再び前面に表示します。この時には初回起動と同じく、

call MainActivity OnCreate.

call App ctor.
call MainPage ctor.

が出力されるはずです。つまり、この時点では、AppMainPage は2つのインスタンスが存在する、という事です。

アプリが起動したらもう一度ホームボタンを押して退避させ、ブレークポイントで止めた状態で、GC.Collect() を数回呼び出します。すると、以下のように出力されるはずです。

call MainActivity finalizer.

call MainPage finalizer.
call App finalizer.

初回起動のときに生成されたオブジェクトが、このタイミングで破棄されました。「アクティビティを保持しない」設定によって、退避した時点で MainActivity はゴミとマークされ、次回新しい MainActivity が生成されます。ですが GC によってゴミが回収されるまでは、MainAcitivity, App, MainPage はそれぞれ複数のインスタンスが存在し得えます。

これは仕方のないことなので、せめてリソースだけは MainActivity.OnDestroy で解放されるように、App を Disposable にしてみます。

public partial class App : Application

{
void IDisposable.Dispose()
{
System.Diagnostics.Debug.WriteLine("call App Dispose .");
}
}

public class MainActivity : global::Xamarin.Forms.Platform.Android.FormsAppCompatActivity
{
private App app;

protected override void OnCreate(Bundle savedInstanceState)
{
// 省略...

this.app = new App();
LoadApplication(this.app);
}

protected override void OnDestroy()
{
(this.app as IDisposable)?.Dispose();
base.OnDestroy();
System.Diagnostics.Debug.WriteLine("call MainActivity OnDestroy.");
}
}

App を Disposable にして、MainActivity の OnDestroy でそれを呼び出すようにすれば、とりあえず「アプリが破棄される」ときに Xamarin.Forms 側のリソースを解放するトリガーとして使えそうです。

しかしゴミとしてマークされた App や MainPage は、Dispose されつつも GC に掃除されるまで生きているので、その間に意図しない問題を引き起こす可能性に注意を払いましょう。

例えば、App.Dispose で DB 接続は閉じたものの、MainPage ではタイマーを使って定期的に DB から値を得ようとしている場合などです。それぞれが決められたライフサイクルの中で動作していれば問題は起こらないでしょうが、手を抜くと後でトラブルが発生しがちです。


まとめ


  • 自分よりも生存期間が長いオブジェクトを使うとき(ViewModel to Usecase)は、「登録」に対しての「解除」が必ず必要です

  • Xamarin.Forms の Page も App も「破棄」のタイミングを掴む方法は公式には存在しないので、IDisposable インターフェースを使って自力で実装するしかなさそうです

  • Usecase クラスは一般的には App の管轄なので、App の Dispose で破棄するとよいと思われます

  • 「Dispose されたオブジェクト」にアクセスしてしまう事を想定して適切なエラー処理をしましょう


おまけ



  • Activity.ApplicationContext を Xamarin.Forms 側のオブジェクトが握ってしまうと、MainActivity が破棄されずにリークするのでしょうね。


  • Android.apps.Application に持たせたオブジェクトは、最も破棄されにくく、生存期間が長くなります。Xamarin.Forms.Application では生存期間が短くて要件を満たせない場合は、Android.apps.Application にリソースを持たせる事ができると思います。ただし前述の通りリークに注意。

  • Android の Service や、Push通知の受信、ウィジェットなどは、MainActivity とは異なる別の Activity または Context になり、Xamarin.Forms とは関係ないオブジェクト達です。それらと通信する際もやはり循環参照と生存期間の長短に気をつけるべきかと。