MvvmCross で Xamarin.Android の Fragment を使う方法と問題点について

Android 3.0 では、画面に表示される項目として、Activity の下に Fragment という要素を使うようになり、タブレット対応やタブの使用などでは必須レベルで使用するようになっています。

MvvmCross には、この機能に対して Fragging モジュールと、FullFragging モジュールという 2 種類の Fragment サポートがあります。 Fragging モジュールは Android 2.x でも使えるようにバックポートされた Support Library の Fragment を使用するもの、FullFragging モジュールは Android 3.0 以降に搭載される標準の Fragment を使用するものとなっています。

Fragging/FullFragging モジュールでは MvxFragment という View クラスが提供されますが、このクラスに関する使用方法がドキュメント上にありません。また、アプリが停止状態になる際の処理等が現状サポートされておらず、そのままの状態で使用するとメモリが少ない機種等で問題になってきます。

この記事では、MvxFragment クラスの使い方と、MvxFragment クラス上で停止機能が使えるように拡張する方法を紹介します。

MvxFragment クラスを使用できるようにする

NuGet で以下のパッケージを導入します。今回は FullFragging モジュールをベースに紹介します。

  • MvvmCross.HotTuna.Droid.FullFragging (開発中のアプリが Android 3.0 以降のみを対象にする場合)
  • MvvmCross.HotTuna.Droid.Fragging (開発中のアプリが Android 2.x も対象にする場合)

Fragment を呼び出せるようにインターフェイス・クラスを追加する

ViewModel を表示するリクエストから Fragment を表示するためには、Activity を表示しようとする通常の動作をフックして、Fragment を表示する処理に置き換えます。

表示中の Activity が、ViewModel に対応する Fragment を含む場合はそれを処理して表示できるよう、インターフェイスを用意します。IFragmentHost インターフェイスを、.Droid のプロジェクトに追加します

using Cirrious.MvvmCross.ViewModels;

namespace RestoreTest.Droid
{
    /// <summary>
    /// Fragment を表示する Activity が持つインターフェイス
    /// </summary>
    public interface IFragmentHost
    {
        /// <summary>
        /// Fragment を表示する
        /// </summary>
        /// <param name="request">表示するリクエスト</param>
        /// <returns>渡されたViewModelRequest が処理されて Fragment の表示処理が実行されたら true、そうでない場合は false 。</returns>
        bool Show(MvxViewModelRequest request);
    }
}

次に、このインターフェイスを持つ Activity が表示されている場合は、Show メソッドを通じてその Activity に ViewModelRequest を渡せるよう、カスタムの ViewPresenter を定義します。

using Cirrious.MvvmCross.Droid.Views;
using Cirrious.MvvmCross.ViewModels;

namespace RestoreTest.Droid
{
    /// <summary>
    /// カスタムの ViewPresenter
    /// </summary>
    public class ViewPresenter : MvxAndroidViewPresenter
    {
        /// <summary>
        /// ViewModelRequest の処理
        /// </summary>
        /// <param name="request">Request.</param>
        public override void Show(MvxViewModelRequest request)
        {
            // 現在の Activity が IFragmentHost を実装していたら
            var host = Activity as IFragmentHost;
            if (host != null)
            {
                // IFragmentHost.Show 経由で Fragment を表示
                if (host.Show(request))
                {
                    // 呼び出したメソッド内で Fragment が表示されればここで終了
                    return;
                }
            }

            // 当てはまらなければ通常の MvxActivity を表示する処理
            base.Show(request);
        }
    }
}

Fragment は Views 以下に Fragments の階層を作ってその中に入れることが多いようです。Fragments フォルダを作って、その中にフラグメント基底クラス BaseFragment を作成し、このクラスをベースクラスとして Fragment を実装します。(後ほど、復帰機能をつけるときに必要になってきます)

using Android.Views;
using Cirrious.MvvmCross.Droid.FullFragging.Fragments;

namespace RestoreTest.Droid.Views.Fragments
{
    public class FragmentBase : MvxFragment
    {
    }
}

Fragment は、Activity と同様にレイアウトファイルを作り、対応するクラスを以下のように定義します(OnCreateView で View を作成します。詳しくは Fragment のライフサイクルについて調べてみてください)。

using Android.OS;
using Android.Views;
using Cirrious.MvvmCross.Binding.Droid.BindingContext;
using Cirrious.CrossCore;

namespace RestoreTest.Droid.Views.Fragments
{
    public class ThirdView : FragmentBase 
    {
        public override View OnCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)
        {
            // MvxFragment の仕様上、必ず base を呼ぶ必要がある。
            // 戻り値は捨てる
            base.OnCreateView(inflater, container, savedInstanceState);

            // 表示する View をレイアウトファイルからを BindingInflate で生成する。
            // BindingInflate でないと local:MvxBind を書いてある箇所でクラッシュするので注意。
            return this.BindingInflate(Resource.Layout.ThirdView, null);
        }
    }
}

あとは、この Fragment を表示する Activity に Fragment を処理する機能を追加します。

using Cirrious.MvvmCross.Droid.Views;
using Android.App;
using Android.OS;
using Cirrious.CrossCore;
using Cirrious.MvvmCross.ViewModels;
using RestoreTest.Core.ViewModels;
using RestoreTest.Droid.Views.Fragments;

namespace RestoreTest.Droid.Views
{
    [Activity(Label = "View for SecondViewModel")]
    public class SecondView : MvxActivity, IFragmentHost
    {
        ThirdView _thirdViewFragment;

        protected override void OnCreate(Bundle bundle)
        {
            SetContentView(Resource.Layout.SecondView);
        }

        #region IFragmentHost implementation

        public bool Show(MvxViewModelRequest request)
        {
            // この画面で表示できる型の ViewModel なら
            if (request.ViewModelType == typeof(ThirdViewModel))
            {
                // View を作って
                _thirdViewFragment = new ThirdView();

                // ViewModel を作って
                var loaderService = Mvx.Resolve<IMvxViewModelLoader>();
                _thirdViewFragment.ViewModel = loaderService.LoadViewModel(viewModelRequest, savedState);

                // Fragment の表示領域に追加する
                var trans = FragmentManager.BeginTransaction();
                trans.Add(Resource.Id.fragmentFrame, _thirdViewFragment);
                trans.Commit();

                return true;
            }
            return false;
        }

        #endregion
    }
}

ここまでの内容は公式のサンプルの通りです。

しかしながら、この方法で作ったアプリは、バックグラウンドに送られた後、ある程度メモリ負荷をかけた状態で復帰しようとした場合クラッシュするか、たまたま表示できたとしても期待する動作をしなくなってしまいます。

これは、ある程度リソースが圧迫されると、Android の OS 側の制御でアプリが強制的に停止されるためです。強制的に停止されたアプリは、元々表示されていた画面で復帰しようとしますが、新規インスタンスとなるため、フィールドやプロパティはすべてクリアされてしまいます。

Activity や Fragment には OnSaveInstanceState というメソッドがあり、このメソッドに渡される Bundle に各種データを保存しておけば OnCreate や OnCreateView などに渡される Bundle で再取得でき、復元することができます。MvvmCross でもこの機能はサポートされていますが、Fragment に関しては処理が実装されていません。

また、Show メソッドで ViewModel を直接セットしましたが、これも初期化されて ViewModel が消えているという状態になってしまいます。Fragment の初期化に関する情報は Argument というプロパティに入れておくことができるので、こちらを使い、Fragment 自身に ViewModel を作らせる必要があります。

Fragment に状態保存機能を追加する

本来であれば MvvmCross 側を修正すべき所ですが、かなり複雑かつ、入れ替えも手間なので拡張メソッドとアプリケーション側のベースクラスを用意する形で対応することにしました。

まず、FragmentExtension を追加します。ここには状態保存や ViewModel のパラメータの引き渡しなどの処理を記述しています。

using System;
using Cirrious.MvvmCross.Droid.FullFragging.Fragments;
using Android.OS;
using Android.Views;
using Cirrious.MvvmCross.ViewModels;
using Cirrious.MvvmCross.Views;
using Cirrious.CrossCore;
using Cirrious.MvvmCross.Droid.Platform;
using Cirrious.MvvmCross.Droid.Views;

namespace RestoreTest.Droid.Views.Fragments
{
    /// <summary>
    /// Fragment 関連の拡張メソッド
    /// </summary>
    public static class FragmentExtensions
    {
        /// <summary>
        /// Arguments に登録する起動パラメータ
        /// </summary>
        public const string ExtrasKey = "MvxLaunchData";

        /// <summary>
        /// ViewModel に Request を登録する
        /// </summary>
        public static void ProvideViewModelRequest(this MvxFragment fragment, MvxViewModelRequest request)
        {
            var bundle = fragment.Arguments ??
                (fragment.Arguments = new Bundle());

            var converter = Mvx.Resolve<IMvxNavigationSerializer>();
            var requestText = converter.Serializer.SerializeObject(request);
            bundle.PutString(ExtrasKey, requestText);
        }

        /// <summary>
        /// Fragment の OnCreateView で呼び出すメソッド
        /// ViewModel を生成・状態復元する。
        /// </summary>
        public static void CreateViewCalled(this MvxFragment fragment, LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)
        {
            fragment.ViewModel = fragment.LoadViewModel(savedInstanceState);
        }

        /// <summary>
        /// Fragment の OnSaveInstanceStateCalled で呼び出すメソッド
        /// ViewModel の状態保存メソッドを呼び出す
        /// </summary>
        public static void SaveInstanceStateCalled(this MvxFragment fragment, Bundle outState)
        {
            var mvxBundle = fragment.CreateSaveStateBundle();
            if (mvxBundle != null)
            {
                IMvxSavedStateConverter converter;
                if (!Mvx.TryResolve<IMvxSavedStateConverter>(out converter))
                {
                    Mvx.Warning("Saved state converter not available - saving state will be hard");
                }
                else
                {
                    converter.Write(outState, mvxBundle);
                }
            }
            var cache = Mvx.Resolve<IMvxSingleViewModelCache>();
            cache.Cache(fragment.ViewModel, outState);
        }

        /// <summary>
        /// ViewModel を読み込む
        /// </summary>
        /// <remarks>MvxActivityViewExtensions の同名メソッドのコピペなので何とかしたい</remarks>
        static IMvxViewModel LoadViewModel(this MvxFragment fragment, Bundle savedInstanceState)
        {
            var viewModelType = fragment.FindAssociatedViewModelTypeOrNull();
            if (viewModelType == typeof(MvxNullViewModel))
                return new MvxNullViewModel();

            if (viewModelType == null
                || viewModelType == typeof (IMvxViewModel))
            {
                Mvx.Trace("No ViewModel class specified for {0} in LoadViewModel",
                               fragment.GetType().Name);
            }

            var extraData = fragment.Arguments.GetString(ExtrasKey);
            if (extraData == null)
                return null;

            var savedState = GetSavedStateFromBundle(savedInstanceState);

            var converter = Mvx.Resolve<IMvxNavigationSerializer>();
            var viewModelRequest = converter.Serializer.DeserializeObject<MvxViewModelRequest>(extraData);

            var loaderService = Mvx.Resolve<IMvxViewModelLoader>();
            var viewModel = loaderService.LoadViewModel(viewModelRequest, savedState);
            return viewModel;
        }

        /// <summary>
        /// SavedInstanceState に保存されている状態オブジェクトを取り出す
        /// </summary>
        /// <remarks>MvxActivityViewExtensions の同名メソッドのコピペなので何とかしたい</remarks>
        static IMvxBundle GetSavedStateFromBundle(Bundle bundle)
        {
            if (bundle == null)
                return null;

            IMvxSavedStateConverter converter; 
            if (!Mvx.TryResolve<IMvxSavedStateConverter>(out converter))
            {
                Mvx.Trace("No saved state converter available - this is OK if seen during start");
                return null;
            }
            var savedState = converter.Read(bundle);
            return savedState;
        }
    }
}

次に、FragmentExtension に定義した処理を呼び出すように、FragmentBase を修正します。

using Android.Views;
using Cirrious.MvvmCross.Droid.FullFragging.Fragments;

namespace RestoreTest.Droid.Views.Fragments
{
    public class FragmentBase : MvxFragment
    {
        public override View OnCreateView(LayoutInflater inflater, ViewGroup container, Android.OS.Bundle savedInstanceState)
        {
            this.CreateViewCalled(inflater, container, savedInstanceState);
            return base.OnCreateView(inflater, container, savedInstanceState);
        }

        public override void OnSaveInstanceState(Android.OS.Bundle outState)
        {
            this.SaveInstanceStateCalled(outState);
        }
    }
}

最後に、Fragment を生成する部分を修正します。ViewModel を直接作るのではなく、拡張メソッドで定義したパラメータ引き渡しの処理を使用するようにします。

public bool Show(MvxViewModelRequest request)
{
    if (request.ViewModelType == typeof(ThirdViewModel))
    {
        _thirdViewFragment = new ThirdView();
        _thirdViewFragment.ProvideViewModelRequest(request);

        var trans = FragmentManager.BeginTransaction();
        trans.Add(Resource.Id.fragmentFrame, _thirdViewFragment);
        trans.Commit();

        return true;
    }
    return false;
}

ViewModel 側の状態保存

なお、ここまでの実装でライフサイクルはつながりましたが、ViewModel で実際に値を読み書きしないと状態復帰させることはできません。

状態保存が必要なところになると、ViewModel の SaveStateToBundle が、状態復元が必要になると LoadStateFromBundle が呼び出されますので、ここで値を読み書きするようにします。

using Cirrious.MvvmCross.ViewModels;

namespace RestoreTest.Core.ViewModels
{
    public class ThirdViewModel : MvxViewModel
    {
        const string HelloKey = "Hello";

        string _hello = "Hello MvvmCross";
        public string Hello
        { 
            get { return _hello; }
            set { _hello = value; RaisePropertyChanged(() => Hello); }
        }

        /// <summary>
        /// コンストラクタ
        /// 1番目に呼ばれる
        /// </summary>
        public ThirdViewModel()
        {
        }

        /// <summary>
        /// ViewModel のパラメータを処理する
        /// 2番目に呼ばれる
        /// </summary>
        /// <param name="parameters">Parameters.</param>
        protected override void InitFromBundle(IMvxBundle parameters)
        {
            base.InitFromBundle(parameters);
        }

        /// <summary>
        /// 状態保存された値を読み込む
        /// 保存されたデータがあれば InitFromBundle の後に呼ばれる
        /// </summary>
        /// <param name="state">State.</param>
        protected override void ReloadFromBundle(IMvxBundle state)
        {
            base.ReloadFromBundle(state);

            if (state != null)
            {
                if (state.Data.ContainsKey(HelloKey))
                {
                    Hello = state.Data[HelloKey];
                }
            }
        }

        /// <summary>
        /// 一番最初に表示されるタイミングで呼ばれる
        /// </summary>
        public override void Start()
        {
            base.Start();
        }

        /// <summary>
        /// 状態保存が必要なタイミングで呼ばれる
        /// </summary>
        /// <param name="bundle">Bundle.</param>
        protected override void SaveStateToBundle(IMvxBundle bundle)
        {
            base.SaveStateToBundle(bundle);
            bundle.Data.Add(HelloKey, Hello);
        }
    }
}

状態復帰のテスト方法

なお、状態復帰は端末の設定「開発者向けオプション」を変えることで簡単にすることができます。

device-2014-07-20-012236.png

  • アクティビティを保持しない
  • バックグラウンドプロセスの上限: 1

この状態で別のアプリに切り替えて戻ってくれば、状態復元が起こることがわかります。

最後に

この記事で取り上げているコードの全体は以下の場所にありますので、併せて参考にしてください。

なお、状態が復元されない点については、すでに MvvmCross の Issue にあがっています。