kamulog

xamarin.formsのネタなど

Xamarin.Forms (Android) のListViewでFFImageLoadingとNativeCellを使ってout of memoryを回避しつつ高速化するサンプル(iOSはViewCellのまま使用する)

まもなくForms.AndroidのFastRendererがstableに降りてくるとは思いますが、現時点ではXamarin.Forms.AndroidのListViewは特に画像を使用した場合のパフォーマンス悪すぎ問題があります。普通にやってたら描画が遅くてメモリリークで落ちがちなので、何らかの対策が必要です。

個人アプリの復習帳では、ListViewやTableViewのセルは全てNativeCellで処理しています。それとは別に仕事で扱ってるプロジェクトではNativeCellに加えてFFImageLoadingのCachedImageを組み合わせた対策を行いました。

そういうわけで、今回はAndroidのListViewを快適に使うためのNativeCell+FFImageLoadingのサンプルをご紹介します。

完成品

github.com

用意するもの

github.com

キャッシュしてくれたり、非同期で画像読み込んでくれたりいろいろよしなにしてくれるライブラリです。

何も対策しない場合

使用する画像は1枚500〜1000KBのそこそこ重いもので、これをセルに配置して全20行をListViewで表示します。

<ListView x:Name="listview" ItemsSource="{Binding Items}" HasUnevenRows="false" RowHeight="400">
    <ListView.ItemTemplate>
        <DataTemplate>
            <ViewCell>
                <StackLayout Spacing="0" Margin="0,0,0,6" BackgroundColor="Silver">
                    <StackLayout.GestureRecognizers>
                        <TapGestureRecognizer Command="{Binding BindingContext.GoDetailCommand,Source={x:Reference listview}}" CommandParameter="{Binding PhotoUrl}" />
                    </StackLayout.GestureRecognizers>
                    <Image Aspect="AspectFill" Source="{Binding PhotoUrl}" HorizontalOptions="FillAndExpand" HeightRequest="300" />
                    <Label Text="{Binding Title,StringFormat='タイトル:{0:N}'}" />
                    <Label Text="{Binding Date,StringFormat='撮影日:{0:N}'}" />
                </StackLayout>
            </ViewCell>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

こんな感じで一瞬で落ちます。

FFImageLoadingを使用する

ImageをCachedImageにしてダウンサンプル幅640を指定しています。 元画像をそのままではなく、幅640まで下げてからキャッシュして表示するという動きをします。

<ListView x:Name="listview" ItemsSource="{Binding Items}" HasUnevenRows="false" RowHeight="400">
    <ListView.ItemTemplate>
        <DataTemplate>
            <ViewCell>
                <StackLayout Spacing="0" Margin="0,0,0,6" BackgroundColor="Silver">
                    <StackLayout.GestureRecognizers>
                        <TapGestureRecognizer Command="{Binding BindingContext.GoDetailCommand,Source={x:Reference listview}}" CommandParameter="{Binding PhotoUrl}" />
                    </StackLayout.GestureRecognizers>
                    <ff:CachedImage DownsampleWidth="640" Aspect="AspectFill" Source="{Binding PhotoUrl}" HorizontalOptions="FillAndExpand" HeightRequest="300" />
                    <Label Text="{Binding Title,StringFormat='タイトル:{0:N}'}" />
                    <Label Text="{Binding Date,StringFormat='撮影日:{0:N}'}" />
                </StackLayout>
            </ViewCell>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

落ちることはなくなって、画像を全部読み込んだ後はかなりスムーズになりました。 というか通常はここまでで十分だと思います。

FFImageLoading + NativeCell

普通にCachedImageを使用してもout of memoryが出るような場合は、NativeCellにすると改善するかも知れません。

ViewCellをNativeCellとして使用するサンプルは公式のこれを元にしています。 https://developer.xamarin.com/guides/xamarin-forms/application-fundamentals/custom-renderer/viewcell/

作成手順は

  1. PCLにViewCellを継承したMyViewCellを作成する
  2. Platform側にViewCellRendererを継承したMyViewCellRendererを作成してExportRenderer属性をつける
  3. Platform側にLinearLayout,INativeElementViewを継承した適当なNative用のCellクラスを作成する
  4. Resources/layoutにレイアウト用のXmlを置く

だいたいこんな感じです。

1 PCLにPhotoViewCellを作成

public class PhotoViewCell : ViewCell
{
    public static BindableProperty PhotoItemProperty =
        BindableProperty.Create(
            nameof(PhotoItem),
            typeof(MainPageViewModel.PhotoItem),
            typeof(PhotoViewCell),
            null,
         defaultBindingMode: BindingMode.OneWay
        );

    public MainPageViewModel.PhotoItem PhotoItem {
        get { return (MainPageViewModel.PhotoItem)GetValue(PhotoItemProperty); }
        set { SetValue(PhotoItemProperty, value); }
    }

    public static BindableProperty CommandProperty =
        BindableProperty.Create(
            nameof(Command),
            typeof(ICommand),
            typeof(PhotoViewCell),
            null,
         defaultBindingMode: BindingMode.OneWay
        );

    public ICommand Command {
        get { return (ICommand)GetValue(CommandProperty); }
        set { SetValue(CommandProperty, value); }
    }
}

Nativeとやりとりする用のヤツです。 個別にTitleとかUrlとか設定しても良いんですが、受け渡しがめんどいので以下のようなクラス単位で渡すようにしています。 Commandはタップしたときのアクション用ですね。

public class PhotoItem
{
    public string PhotoUrl { get; set; }
    public string Title { get; set; }
    public string Date { get; set; }

}

2 Renderer作成

public class PhotoViewCellRenderer : ViewCellRenderer
{
    PhotoViewCell _formsCell;
    PhotoNativeCell _nativeCell;

    protected override Android.Views.View GetCellCore(Xamarin.Forms.Cell item, Android.Views.View convertView, Android.Views.ViewGroup parent, Android.Content.Context context)
    {
        _formsCell = item as PhotoViewCell;
        _nativeCell = convertView as PhotoNativeCell;

        if (_nativeCell == null) {
            //TODO:新規セル生成処理
            _nativeCell = new PhotoNativeCell(context, _formsCell);
        }
        else {
            //TODO:リサイクル時のイベント解除等の初期化処理

            //前のタスクが未完了で未キャンセルの場合はキャンセルする
            if (_nativeCell.CurrentTask != null && !_nativeCell.CurrentTask.IsCancelled && !_nativeCell.CurrentTask.IsCompleted) {
                _nativeCell.CurrentTask.Cancel();
            }

            //NativeCellが持っているformsCellの参照の更新
            _nativeCell.PhotoViewCell = _formsCell;

            _nativeCell.SetOnClickListener(null);
        }

        //読み込み完了までの画像など(ここでは透明)
        _nativeCell.ImageView.SetImageResource(global::Android.Resource.Color.Transparent);

        //イメージ読み込み開始
        _nativeCell.CurrentTask = ImageService.Instance.LoadUrl(_formsCell.PhotoItem.PhotoUrl).DownSample(width: 640).Into(_nativeCell.ImageView);
        //Finalize時にCachedImageのメモリ上のキャッシュをクリアするためのキー
        _nativeCell.ImageView.Key = _formsCell.PhotoItem.PhotoUrl;

        //テキストの更新
        _nativeCell.Title.Text = _formsCell.PhotoItem.Title;
        _nativeCell.Date.Text = _formsCell.PhotoItem.Date;

        _nativeCell.SetOnClickListener(_nativeCell);

        return _nativeCell;
    }
}

GetCellCoreをoverrideしてそこに新規セル生成処理とリサイクル時のセルの処理と共通処理とを書いていきます。 引数のitemはformsのcellが(ここではPhotoViewCell)が送られて来ます。convertViewは対応するNativeCellが入って来ます。このconvertViewがnullの場合は新規、値が入っていればリサイクルセルとなります。 ViewCellの中身はAndroidでは不要なのでbaseは呼びません。

新規の場合は単純に対応するNativeCellをnewするだけで良いですが、リサイクルの場合は使い回し前の値やイベントをクリアする処理が必要です。

このif/elseの後は共通の処理を書きます。基本的にはイベントの購読とformsの値をnativeに設定する処理です。

で最後にnativeCellを返却します。

今回はFFImageの処理が例外的に入っていますが、基本的にはそれを省いたものがテンプレとして使えると思います。

なおListViewのCachingStrategyはRetainElement前提です。RecycleElementの場合は少し手順が異なります。

ImageServiceが何をしているか

FFImageLoadingのサービスでなんかいろろできるやつですw ここではリサイクル時に前回のタスクが終わってなければ無駄なのでキャンセルして、その後前の画像が表示されたままにならないように透明のdrawableをセットしています。 それから現在表示すべき画像をロードしてImageViewにセットするというようなことをしています。

イベントの購読・解除に注意

サンプルでは

_nativeCell.Click += hoge;
_nativeCell.Click -= hoge;

のようなイベント購読・解除の方法はとらずにSetOnClickListenerの方法にしています。 リサイクル用のセルが来るRendererが前回と同じRedererとは限らないっぽいので、上記のようにしているとハンドラが溜まりっぱなしになってクリックイベントが重複しまってしまいました。 イベントはNativeCell側に持たせてやる方が良さそうです。

3 NativeCellの作成 + FFImageLoading.DroidのImageViewAsyncの拡張

2の新規セル作成時に生成するセルのクラスです。

public class PhotoNativeCell : LinearLayout, INativeElementView, Android.Views.View.IOnClickListener
{
    public PhotoViewCell PhotoViewCell { get; set; }
    public Element Element => PhotoViewCell;
    public IScheduledWork CurrentTask { get; set; }

    public MyImageView ImageView { get; set; }
    public TextView Title { get; set; }
    public TextView Date { get; set; }

    public PhotoNativeCell(Context context, Cell cell) : base(context)
    {
        var view = (context as FormsAppCompatActivity).LayoutInflater.Inflate(Resource.Layout.PhotoViewCell, this, true);

        PhotoViewCell = cell as PhotoViewCell;

        ImageView = view.FindViewById<MyImageView>(Resource.Id.PhotoViewImage);
        Title = view.FindViewById<TextView>(Resource.Id.PhotoViewTitle);
        Date = view.FindViewById<TextView>(Resource.Id.PhotoViewDate);

    }

    public void OnClick(Android.Views.View v)
    {
        PhotoViewCell.Command?.Execute(PhotoViewCell.PhotoItem.PhotoUrl);
    }
}

Resourceからlayoutを呼び出して自身にセットして各連携Viewの参照を取得しています。 Inflateの第二引数にはnullではなく自分自身をセットした方が正しく動きました。 ここで自分自身をセットすると自動的にAddViewしてくれるみたいなので AddView(view); みたいに書いてしまうと重複してviewがセットされてエラーになってしまいました。

ちなみにINativeElementViewは必須ではないと思いますが、公式サンプルでも使ってたので一応適用しています。

今回はFFImageLoading.Forms.Droidが通常使うImageViewAsyncではなくて以下のようにJavaFinalizeの時にちゃんとリソースを解放するようにしたものを使うようにしています。

ImageService.Instance.InvalidateCacheEntryAsync(Key, FFImageLoading.Cache.CacheType.Memory);

これはFFImageLoadingのメモリ上のキャッシュをクリアする処理です。キーとしてUrl文字列が必要なのでプロパティとして持たせてRendererで代入するようにしています。 FFImageloadingのメモリ上のキャッシュはほっといたら増えっぱなしになると公式ドキュメントに書いてあったのでその対策です。

[Preserve(AllMembers = true)]
[Register("sample.droid.cells.MyImageView")]
public class MyImageView : ImageViewAsync
{
    public string Key { get; set; }

    public MyImageView(Context context) : base(context)
    {
    }

    public MyImageView(IntPtr javaReference, JniHandleOwnership transfer) : base(javaReference, transfer)
    {
    }

    public MyImageView(Context context, IAttributeSet attrs) : base(context, attrs)
    {
    }

    protected override void JavaFinalize()
    {
        SetImageDrawable(null);
        SetImageBitmap(null);
        ImageService.Instance.InvalidateCacheEntryAsync(Key, FFImageLoading.Cache.CacheType.Memory);
        base.JavaFinalize();
    }
}

RegisterはxmlでカスタムViewを使うためのものです。 PreserveはAOTで削られないためだと思いますが、FFImageLoadingのソースの真似をしただけなのでよく分かってませんが一応つけました。

4 layoutのxmlの作成

xmlでレイアウトを作成します。PhotoViewCell.axmlとしました。

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="406dp"
    android:minHeight="406dp">
    <sample.droid.cells.MyImageView
        android:id="@+id/PhotoViewImage"
        android:scaleType="centerCrop"
        android:layout_alignParentTop="true"
        android:layout_width="match_parent"
        android:layout_height="300dp"
        android:minHeight="300dp" />
    <TextView
        android:id="@+id/PhotoViewTitle"
        android:layout_below="@id/PhotoViewImage"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />
    <TextView
        android:id="@+id/PhotoViewDate"
        android:layout_below="@id/PhotoViewTitle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />
    <LinearLayout
        android:background="#FFFFFF"
        android:layout_alignParentBottom="true"
        android:layout_width="match_parent"
        android:layout_height="6dp" />
</RelativeLayout>

RelativeLayout便利すぎですね。

Xamlで仕上げ

以下のようにすることで、iOSではCachingStrategyはRecycleでPhotoViewCellはRendererで定義してないので、通常のViewCell扱いになりPhotoViewCellの中身を表示するようになり、AndroidではCachingStrategyはRetainでRendererでViewCellの中身は捨ててるので(baseを呼んでいない)、PhotoViewCellに対応するNativeCellで描画されるようになります。

<ListView x:Name="listview" ItemsSource="{Binding Items}" HasUnevenRows="false" RowHeight="400">

    <x:Arguments>
        <ListViewCachingStrategy>
            <OnPlatform x:TypeArguments="x:String">
                <On Platform="iOS">RecycleElement</On>
                <On Platform="Android">RetainElement</On>
            </OnPlatform>
        </ListViewCachingStrategy>
    </x:Arguments>
    
    <ListView.ItemTemplate>
        <DataTemplate>
            <cell:PhotoViewCell PhotoItem="{Binding}" Command="{Binding BindingContext.GoDetailCommand,Source={x:Reference listview}}">
                <!-- ↑のプロパティはAndroidのみ有効 -->
                <!-- ↓のViewCellの中身はiOSのみ有効 -->
                <StackLayout Spacing="0" Margin="0,0,0,6" BackgroundColor="Silver">
                    <StackLayout.GestureRecognizers>
                        <TapGestureRecognizer Command="{Binding BindingContext.GoDetailCommand,Source={x:Reference listview}}" CommandParameter="{Binding PhotoUrl}" />
                    </StackLayout.GestureRecognizers>
                    <ff:CachedImage DownsampleWidth="640" Aspect="AspectFill" Source="{Binding PhotoUrl}" HorizontalOptions="FillAndExpand" HeightRequest="300" />
                    <Label Text="{Binding Title,StringFormat='タイトル:{0:N}'}" />
                    <Label Text="{Binding Date,StringFormat='撮影日:{0:N}'}" />
                </StackLayout>
            </cell:PhotoViewCell>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

速さはViewCell+FFImageLoadingの場合と変わりませんね… まぁメモリ効率はきっとよくなっているはず。

終わりに

当然のことながら、ネットワーク経由で使用する画像にこんなデカイ画像を使ってはいけませんw

それはさておき、通常はImageをCachedImageに変えるだけで問題ないと思います。 それでもメモリーがかつかつの場合はNativeCellを組み合わせてみるという感じですかね。その場合Viewを1つ余分に作らないといけないという結構なデメリットがありますが、複雑なデザインであればあるほど、NativeCellにした時の恩恵も大きくなるので、何を重視するかで決めたら良さそうです。

長すぎる記事を読んでいただきありがとうございました。

写真の素材

sozai-free.com