滅入るんるん

何か書きます

【C#】Xamarin.FormsのApplication.Current.Propertiesより高性能な設定保存ライブラリ作った話

タイトルは半分釣りです(ごめんなさい)
(半分大真面目)

作った動機

もともと、こんなライブラリ作るつもりなんてなかったんですが、理想のMVVM Navigatorを製作中にXamarin.AndroidでのViewModelの状態保持がきついなと。

どういうことかというと、AndroidではView(Activity, Fragment)間の画面遷移時のデータのやり取りはIntentまたはBundleクラスを経由するんですが、そいつらに渡せるデータはJavaで言うところのプリミティブ型であったりJavaのSerializableインターフェースを実装したものまたはAndroidのParcelableインターフェースを実装したもののみなんですよ。

そんなインターフェースをMVVMのViewModelであったりModelに持ってこれるわけもなく、普段ならModelをJson化して文字列を画面遷移時に飛ばしていました。そういう標準的な仕組みを使わずにアプリケーションレイヤーでゴネゴネと頑張ってたわけですが、理想のMVVM Navigatorを作るとなると何らかの機構を用意しなければいけないと。

また、他にも画面遷移以外にも画面回転でViewが一度死ぬことにも対応しないといけません。これは前述したBundleクラスでデータを保持することで対応できるのですが、同じくMVVMではきつい。

.NET/Xamarin.Forms的にはそういうときはおとなしくViewModelが一度死んで、データは永続化して対応するという手法もあるようなので、それを参考にしようかなと。

AndroidのSharedPreferencesは実装がいいとは言えない

前述のわけで、データの永続化方面に着目していくわけですが、AndroidのSharedPreferencesクラス(Android標準の設定保存する奴)は実装がいいとは言えません。

なぜかというと理由は二つあって、

  1. コンストラクターで非同期的にファイルを読み込んで、読み込み終わるまでロックする
  2. メソッドはすべてロックする

という感じです。

1.のコンストラクターでの非同期ファイル読み込みはこのあたりのソースを読めばわかります。
async/awaitがないJavaならではの回避方法でしょうが、コンストラクターで非同期処理始めちゃうってのはいかがなモノかなと思いますね。

2.のメソッドはすべてロックするってのは1.のせいもありますが、マルチスレッドでアクセスしない場合では無駄なロックになってしまいます。ソースはこのあたりとか

また、実装によって良い悪いはいいがたいものですが、設定値をすべてObject型にしてBoxingを発生させてるのもあまりいただけない。(まぁ素直にBoxingしたほうが早いときもありますが)

Xamarin.AndroidならAndroid(Java)のクラスをわざわざ使わなくてもいいんじゃね?

前述の通りAndroid標準の設定を保存するクラスはいい実装ではないです。また、Xamarin.Androidの場合、MonoAndroidランタイムとAndroid-Java側のランタイムでのオブジェクトのマーシャリングコストがあるので、C#のみで扱う処理に関してはわざわざAndroidの標準のを使わないほうがパフォーマンスがいいはずです(ちゃんとした計測はしてないので理論的な話)

Xamarin.Formsはどうしてるのか?

タイトルからわかりますが、Xamarin.FormsならApplication.Current.Propertiesで設定の保存とかできます。
Applicationクラスのソースを見る限り、設定値はobjectとして持っています。
(アプリ側で取得したらキャストする必要がある)

そのあたりから追っていくとIDeserializerインターフェースに遭遇しますが、それの実装は当たり前ですが、各プラットフォームにあって、たとえばUWPならWindowsSerializerというクラスで実装しています。(おいまて、なぜIDeserializerインターフェースで名前がWindowsSerializerになる)

このあたりのコードを読むと各プラットフォーム独自にファイルを作って独自にシリアライズしてる感じということは読み取れます。

.NET Standard 2.0ならもっといいもの作れるんじゃね?

時代は.NET Standard 2.0ですが、IsolatedStorageFileを使えば各プラットフォームでファイルパスを気にせずにアプリケーション固有な領域でファイルを読み書きできます。

また、アプリケーション設定は暗号化とかの需要があります。一昔前はPCLCryptoでクロスプラットフォームな暗号化をしていたかと思いますが、今は.NET Standard 2.0でクロスプラットフォームな暗号化ができます。(サポート内容はこのあたりをみるとわかるかと)

よし、作ってみよう

下調べ的にはよさそうだったので、作ってみることにしました。
しかし、普通に作っていてはファイル書き込み時のシリアライズがつらいです。
また、それなりにはJson形式で保存したいところだったので(XMLを辞める)、.NET Standard 2.0で最速と言って良さそうなUtf8JsonをJsonシリアライザーとして使うことに。

ただ、.NET Core 2.1のパフォーマンスチューニングによって.NET Core 2.1向けの設計・ライブラリでUtf8Json越えをしたっぽいJsonシリアライザーがあったので、SpanJsonにも対応させることにしました。

Utf8JsonとSpanJsonは設計がよくて障壁があまりなかった

パフォーマンスチューニングしまくると設計が似たよったりになるのか、二つとも似たような設計になっていました。

Valueのシリアライズ・デシリアライズにIJsonFormatterインターフェースと各型向けの実装。名前解決や、どのIJsonFormatterを使うか解決するためのIJsonFormatterResolverインターフェースとその実装に分かれていました。

そのため、こちらとしては適切なIJsonFormatterやIJsonFormatterResolverと型を結び付けるだけでよかったのです。

ユーザー定義型にも対応させたい

まぁ、普通に作ってたらユーザー定義型を保存する際に不便になるので、ユーザー定義型を標準でサポートすることに。

ユーザー定義型をJsonで保存するには、Jsonから読み取るときにどういう型なのか判断できなければいけません。

アプローチはたぶん2通りあると思います。

  1. 型情報をJsonに載せる
  2. Keyから型情報を判断する

2.のほうは読み取る型の種類があらかじめ決まっていれば可能ですが、決まってないので現実的ではないです。そこで、型情報をJsonに載せることにしました。

そこで先日書いた記事([C#]Type.GetType(string)するときはアセンブリ情報がないほうが早い)が関わってきます。
記事の中では名前空間+型名が最速と書いていますが、アセンブリ情報がないとアセンブリを読み込んでない時に型を取得できなかったので、最低限のアセンブリ情報を載せるようにしました。

Jsonの形式どうしよう

型情報に基づいたシリアライズをするので、実際の設定値より先に型情報を読み込む必要があります。

アプローチとしては2通り考えれて

  1. 型情報のあとに設定値が来ないといけない制限を付ける
  2. 型情報をKey、設定値をValueで配置する

1.のほうはJsonっぽい形になっています。ただ、実装レベルの制限がついてしまう()
そして、無駄にKey情報もあるのでパフォーマンスもちょっと落ちます。
2.のほうは無駄なKey情報を省いて完全にチューニングした結果です。Jsonとしてもはや違法すれすれの形式なのであまり褒めたものではないです。

結果的に、二つの形式に対応したものとしました。(標準では2.の形式が使われる)
まぁ、そんな裏側のところ弄る人もいないだろうという判断です。

暗黙的な型変換どうしよう

作るものとしてはユーザー定義型にも対応させたいし、いちいち専用のメソッド叩くのも嫌なので、ジェネリクスを使ってます。(getとかsetのとこ)

で、問題になるのはget時の暗黙的な型変換です。
Xamarin.FormsのApplication.Current.Propertiesではアプリケーションレイヤー側が明示的なキャストをしてますが、AndroidのSharedPreferencesでは内部でいい感じにキャストしてます。

暗黙的に変換しても問題ないものはget時に型パラメーターの型へ暗黙的な型変換してもいいだろうとも考えれます。

そこで暗黙的な型変換はどういうのがあるのか、整理することにしました。

  • アップキャスト
  • 共変性・反変性
  • Nullable化
  • 数値変換
  • implicit operator

このうちimplicit operator以外は手ごろに何とかなりました。
implicit operatorに関してはどうも式ツリーで動的にコード生成しないといけなさそうだったので、Let's go 式ツリーということに;;
そのとき発見した黒魔術がこの記事です([C#][黒魔術] implicit operator: あいまいなユーザー定義の変換を回避する方法)

暗黙的型変換に関してはサポートしていくつもりですが、C# 8.0の変更でサポートし続けれるかわからない点と単にパフォーマンスもよくない(式ツリーとか使ってるからね)ので、お勧めはしない機能扱いです。
普通に正確な型を型パラメーターに設定しろ案件

成果物

https://github.com/MeilCli/SharedProperty

READMEをそれなりに頑張って書いたので日本語版のREADMEを読めばそれなりにわかると思います。

もちろんNuGetにも公開していて、Utf8Jsonを使う場合はこっち、SpanJsonを使う場合はこっちをインストールすればいいです。

今後の展望的なもの

最初のほうにロックするから遅いんだ!!!みたいなことを言いましたが一応スレッドセーフなものも用意しているんです。

ただ、一応()って感じに中身の実装は雑なので、そこはどうにかしたいところです。
あまりいい実装方法が思いつかないので、もしいい実装方法があったら教えてくださいm(__)m

PR/Issueももちろん受け付けてますm(__)m