C#
iOS
Xamarin
XamarinDay 22

Xamarin.iOS で UIGestureRecognizer を使うときのお作法


Problem

UIGestureRecognizer とその継承クラスはなんらかのビューにジェスチャを付けたいときに使います。たとえば UIView がタップされたことを検知したいなら次のようになるでしょう。


ViewController.cs

public override void ViewDidLoad()

{
var tapGesture = new UITapGestureRecognizer(this.HandleTapped);
view.AddGestureRecognizer(tapGesture);
}

private void HandleTapped()
{
Console.WriteLine("Tapped!");
}


おめでとうございます。これであなたは解放されない ViewController を手に入れました。

サンプルはこちらです。https://github.com/ailen0ada/XamarinAdvent18Sample

色の名前をタップすると全面に色を表示する画面に遷移し,タップすると透明度が変化するようになっています。行をタップしたときに GC.Collect を走らせているので,きちんと解放されればファイナライザが呼ばれてデバッグコンソールに出力が行われるはずです。解放されれば。


TL;DR


  • 強参照

  • ちゃんと参照を外すか, ObjCRuntime.Selector を使ったアプローチに変える

  • そのうちもう少し簡単に解放できるようになる


Cause

Action を渡すコンストラクタの内部で,別のクラスが作られてにここで渡した Action が抱え込まれるからです。おおよそ次のようなコードに展開されます。


UIGestureRecognizer.ctor

[DesignatedInitializer]

public UIGestureRecognizer(Action action)
: this(Selector.GetHandle("target"),
(UIGestureRecognizer.Token)
new UIGestureRecognizer.ParameterlessDispatch(action))
{
}

[Register("__UIGestureRecognizerParameterlessToken")]
public class ParameterlessDispatch : UIGestureRecognizer.Token
{
private Action action; // ここで抱え込まれて誰も解放しない

internal ParameterlessDispatch(Action action)
{
this.action = action;
}

[Export("target")]
[Preserve(Conditional = true)]
public void Activated()
{
this.action();
}
}


ネイティブ側の UIGestureRecognizer が要求するのはセレクタと,そのセレクタを受け取るターゲットのポインタです。いろいろXamarin側でコンストラクタを定義していますが最終的にはそこに行き着きます。詳しくは拙著 Xamarin Myth Busters をどうぞ。


Solution


A; TapGesture をビューから剥がす

プッシュ遷移の際には,必ず DidMoveToParentViewController を通ることになっているので,これを使います。


UIViewController.cs

public override void DidMoveToParentViewController(UIViewController parent)

{
base.DidMoveToParentViewController(parent);
if (parent == null) // 戻る遷移の場合は parent が null
{
foreach (var recognizer in this.View.GestureRecognizers)
{
this.View.RemoveGestureRecognizer(recognizer);
}
}
}

これで ViewController については解放されます。


B; コンストラクタでセレクタを渡す

次のように書き換えて,ネイティブ呼び出しに近い形にしてしまいましょう。


UIViewController.cs

public override void ViewDidLoad()

{
var tapGesture = new UITapGestureRecognizer(this, new ObjCRuntime.Selector(nameof(HandleTapped)));
view.AddGestureRecognizer(tapGesture);
}

[Foundation.Action(nameof(HandleTapped))]
private void HandleTapped()
{
Console.WriteLine("Tapped!");
}


これでターゲットである UIViewController のポインタと,セレクタが渡されるだけになったので参照が弱くなりました。


C; Dispose() する

Xamarin.iOS 12.2.1.12 の時点ではまだ実装が入っていませんが,きちんと UIGestureRecognizer.Dispose() を呼べば抱えているものをすべて解放する実装がそのうち入ります。

https://github.com/xamarin/xamarin-macios/issues/4190

マージされた箇所を見ると,どこにアタッチされたかを保持しておいて全部解除していくという動きをしています。Aパターンとやることは同じで方向が逆ということですね。

https://github.com/xamarin/xamarin-macios/blob/9456474948db7a32c95d6e2ecf0ce3114746e67a/src/UIKit/UIGestureRecognizer.cs#L25


Conclusion


  1. 参照について意識しましょう。

  2. Rxを使うなら Subscribe したらちゃんと Dispose しましょう。

  3. Xamarin Profilerが使える環境なら使いましょう。(ライセンスはあってもアプリの構造によっては使い物にならないのじゃよ)