kamulog

xamarin.formsのネタなど

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

Xamarin.Forms の ListView は、Android で使う場合(特に画像を扱う場合)にパフォーマンスが悪いという問題があります。これは Fast Renderer を有効にしても変わりません。 普通にやってたら描画が遅くてメモリリークで落ちがちなので、何らかの対策が必要です。

個人アプリの復習帳では、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='Title{0:N}'}" />
                    <Label Text="{Binding Date,StringFormat='Taken at:{0:N}'}" />
                </StackLayout>
            </ViewCell>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

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

FFImageLoadingを使用する

FFImageLoadingのCachedImageを使って上のような問題を回避します。

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

<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="320" Aspect="AspectFill" Source="{Binding PhotoUrl}" HorizontalOptions="FillAndExpand" HeightRequest="300" />
                    <Label Text="{Binding Title,StringFormat='Title{0:N}'}" />
                    <Label Text="{Binding Date,StringFormat='Taken at:{0:N}'}" />
                </StackLayout>
            </ViewCell>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

画像読み込み前

画像読み込み後

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

FFImageLoading + NativeCell + その他のメモリエラー対策

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

ViewCellをNativeCellとして使用するサンプルはXamarin公式のこれを元にしています。

Customizing a ViewCell - Xamarin | Microsoft Docs

作成手順は以下のような感じです。

  1. NETStandardプロジェクトにViewCellを継承したMyViewCellを作成する
  2. Android Platform側の Resources/layout にXMLファイルを作成しレイアウトを定義する
  3. Android Platform側に LinearLayout を継承した適当な native cell クラスを定義し INativeElementViewを実装する
  4. Android Platform側に ViewCellRendererを継承したMyViewCellRendererを作成してExportRenderer属性をつける

また、その他の Out of memory エラー対策として以下があります。

  1. AndroidManifext.xml の largeHeap オプションを有効にする(もっとも効果的です)
  2. MainActivity クラスに OnTrimMemory のオーバーライドを追加する
  3. ImageViewAsync を継承した MyImageViewAsync クラスを定義する

この辺りの詳細は、FFImageLoadingのwikiを参照してください。

Advanced Usage · luberda-molinet/FFImageLoading Wiki · GitHub

以下に具体的な手順を書いていきます。

NETStandardプロジェクトにViewCellを継承した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; }

}

Resources/layout に XMLファイルを作成しレイアウトを定義する

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

このサンプルでは、メモリを効率よく使うために カスタム ImageView を使っていますが、別に普通の ImageView でも大丈夫です。

カスタムのImageViewについては ImageViewAsyncを継承したMyImageViewAsyncを作成する のセクションで詳しく説明します。

<?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>

NativeCellクラスを定義する

このセルはnativeのListViewに実際に表示されるものです。 コンストラクタで、対応するレイアウトを呼び出して、各連携Viewの参照を取得して、OnClickListenerを追加しています。

Inflateの第二引数にはnullではなく自分自身をセットした方が正しく動きました。 ここで自分自身をセットすると自動的にAddViewしてくれるみたいなので AddView(view); みたいに書いてしまうと重複してviewがセットされてエラーになってしまいました。

UpdateCellメソッドでは、現在のセルの内容を、適切に更新されるように前回タスクをクリアしてから更新しています。 具体的には、前回の画像ロードタスクが実行中の場合はキャンセルし、それから現在のタスクを実行するようにしています。 この処理をしないと、必要ない無駄な処理が走りっぱなしになってパフォーマンス上よろしくありません。

CellPropertyChangedメソッドでは、CachingStrategyがRecycleElementの場合に、スクロールでCellが更新された時に、UpdateCellメソッドを呼ぶようにしています。 またFormsのCellの値が動的に更新された場合にも呼び出されます。 これは RetainElement の場合で動的更新がない場合は省略可能です。

INativeElementViewは、RecycleElementを使う場合に使われるので、その場合は実装する必要があります。

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, PhotoViewCell formsCell) : base(context)
    {
        var view = (context as FormsAppCompatActivity).LayoutInflater.Inflate(Resource.Layout.PhotoViewCell, this, true);

        PhotoViewCell = formsCell;

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

        SetOnClickListener(this);
    }

    public void CellPropertyChanged(object sender,PropertyChangedEventArgs e)
    {
        if (e.PropertyName == PhotoViewCell.PhotoItemProperty.PropertyName) {
            // RecycleElement または Forms Cellを動的更新する場合にNativeCellを更新する
            UpdateCell();
        }
    }

    public void UpdateCell()
    {

        // If the previous task is no completed and no canceled, the task is canceled. 
        if (CurrentTask != null && !CurrentTask.IsCancelled && !CurrentTask.IsCompleted) {
            CurrentTask.Cancel();
        }

        // ロード完了までに表示する代わりの画像(ここでは透明をセット)
        ImageView.SetImageResource(global::Android.Resource.Color.Transparent);

        // Begin loading the image
        CurrentTask = ImageService.Instance.LoadUrl(PhotoViewCell.PhotoItem.PhotoUrl).DownSample(320).Into(ImageView);
        // Set the key in order to clear the CachedImage memory cache when finalizing.
        ImageView.Key = PhotoViewCell.PhotoItem.PhotoUrl;

        // Update the cell's text
        Title.Text = PhotoViewCell.PhotoItem.Title;
        Date.Text = PhotoViewCell.PhotoItem.Date;
    }

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

Custom Renderer の作成

このクラスはForms CellとNative Cellを結びつけるものです。

GetCellCoreメソッドでは、itemにはForms Cellが、convertViewにはnullかNative Cellが入って来ます。 convertViewがnullの場合は、新規であることを意味し、そうでなければリサイクルのセルであることを意味します。 なので前者の場合は新しくNative Cellを生成する必要があり、後者の場合は、前回使用したセルの中身をクリーンアップする必要があります。 どちらの場合も、Forms Cellの参照とPropertyChangedイベントの更新が必要です。

具体的な処理はNativeCell側で実行されます。詳細についてはNative Cellクラスの定義 のセクションを参照してください。

[assembly: ExportRenderer(typeof(PhotoViewCell), typeof(PhotoViewCellRenderer))]
namespace Sample.Droid.Cells
{
    public class PhotoViewCellRenderer : ViewCellRenderer
    {
        protected override Android.Views.View GetCellCore(Xamarin.Forms.Cell item, Android.Views.View convertView, Android.Views.ViewGroup parent, Android.Content.Context context)
        {
            var formsCell = item as PhotoViewCell;
            var nativeCell = convertView as PhotoNativeCell;

            if (nativeCell == null) {
                // Creating a new real native cell. 
                nativeCell = new PhotoNativeCell(context, formsCell);
            }

            // Unsubscribe the privious formscell propertychanged event on the nativecell.
            nativeCell.PhotoViewCell.PropertyChanged -= nativeCell.CellPropertyChanged;

            // NativeCellから参照されているFormsCellを更新する
            nativeCell.PhotoViewCell = formsCell;

            // Subscribe the current formscell propertychanged event on the nativecell.
            nativeCell.PhotoViewCell.PropertyChanged += nativeCell.CellPropertyChanged;

            // NativeCellの内容を現在のFormsCellの内容で更新する
            nativeCell.UpdateCell();

            return nativeCell;
        }
    }
}

XamlでNativeCell(Android)を使う

このサンプルでは、AndroidでだけNativeCellを使うために OnPlatform を使って iOSでだけViewCellの中身を使うようにXamlを記述しています。

これでAndroidではPhotoViewCellを使って、iOSではViewCellを使うようになります。

<ListView x:Name="listview" CachingStrategy="RecycleElement" ItemsSource="{Binding Items}" HasUnevenRows="false" RowHeight="400">
    <ListView.ItemTemplate>
        <DataTemplate>
            <cell:PhotoViewCell PhotoItem="{Binding}" Command="{Binding BindingContext.GoDetailCommand,Source={x:Reference listview}}">
                <!-- Above properties are just available on Android -->
                <!-- Below ViewCell content is just available on iOS -->
                <ViewCell.View>
                    <OnPlatform x:TypeArguments="View">
                        <On Platform="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="320" Aspect="AspectFill" Source="{Binding PhotoUrl}" HorizontalOptions="FillAndExpand" HeightRequest="300" />
                                <Label Text="{Binding Title,StringFormat='Title:{0:N}'}" />
                                <Label Text="{Binding Date,StringFormat='Taken at:{0:N}'}" />
                            </StackLayout>
                        </On>
                    </OnPlatform>
                </ViewCell.View>
            </cell:PhotoViewCell>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

速度的にはViewCell + FFImageLoadingとあまり変わりませんね…。まぁメモリ効率はきっとよくなっているはず。

AndroidManifest.xml で largeHeap オプションを有効にする

アプリで使うメモリサイズ自体を大きくしたい場合は、次のように AndroidManifest.xmlandroid:largeHeap="true" を書いて largeHeap を有効にします。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:versionCode="1" android:versionName="1.0" package="kamusoft.sample">
    <uses-sdk android:minSdkVersion="21" />
    <application android:label="Sample" android:largeHeap="true"></application>
</manifest>

これはメモリ効率とは関係ないですが、Out of memory エラーに関しては最も効果的だと思います。

MainActivityクラスに OnTrimMemory を追加する

公式wikiの通りです。

Advanced Usage · luberda-molinet/FFImageLoading Wiki · GitHub

public class MainActivity : global::Xamarin.Forms.Platform.Android.FormsAppCompatActivity
{
    ...
    public override void OnTrimMemory([GeneratedEnum] TrimMemory level)
    {
        ImageService.Instance.InvalidateMemoryCache();
        GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
        base.OnTrimMemory(level);
    }
}

ImageViewAsyncを継承したMyImageViewAsyncを作成する

このカスタムImageViewAsyncは、JavaFinalizeの時にメモリをクリアします。 またカスタムViewをXMLで使えるようにするための属性を付加しています。 書いててよくわかりませんがw、以下の公式wikiのまんまです。

Advanced Usage · luberda-molinet/FFImageLoading Wiki · GitHub

[Android.Runtime.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();
    }
}

終わりに

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

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

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

写真の素材

sozai-free.com