kokoro.ioアプリが引っかかったXamarin.Formsの罠を振り返る (中編)
記事数の水増しなどのやむにやまれぬ事情により前編がkokoro.ioに関係のない一般論だけで終わってしまいましたが、いよいよkokoro-io-appの実装に触れていくことにしましょう。以下ではリポジトリのコミットログを過去から順に振り返っていくことにします。
不具合の歴史
Xamarin.Formsアプリケーションテンプレート
最初期のアプリはMainPage
としてTabbedPage
を使用し、2個のタブに特定のContentPage
派生型を表示しているNavigationPage
を追加していました。とはいってもこれはXamarin.Formsプロジェクトのテンプレートをそのままコミットしたものです。なんだかタブとスペースが混在してGithub上ではインデントがガタガタになっていますが、Xamarin.Formsなので仕方ないです。
初期のMessagesPage
下のXAMLはバインディングのみ抜粋したものです。
<ContentPage>
<ContentPage.Content>
<ListView
ItemsSource="{Binding Messages}">
<ListView.ItemTemplate>
<DataTemplate>
<ViewCell>
<Grid>
<Image
Source="{Binding Profile.Avatar}" />
<StackLayout>
<Label
Text="{Binding Profile.DisplayName}" />
<Label
Text="{Binding PublishedAt}"/>
</StackLayout>
</Grid>
</ViewCell>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</ContentPage.Content>
</ContentPage>
現在のアプリではメッセージを表示する際にWeb版と似たようなHTMLを生成してWebView
内で表示する方針を取っていますが、当初はListView
を使用していました。マークアップを再現するのに必要な作りこみの手間は膨大ですが、ブラウザーコンポーネントよりリソース使用量が抑えらるので最終的なベストになると考えたからです。残念ながら組み込みのLabel
ではリッチテキストの表示はできてもハイパーリンクの埋め込みなどは難しそうだったので、独自のレンダラーを投入することにしました。
MessageInfo
内で取得したHTMLを解析し、MessageContentRenderer
でRichTextBox
やSpannableStringBuilder
などのプラットフォーム別APIでせっせとリッチテキストをくみ上げていたわけですが、このネイティブコントロールをボトムアップで強化していく方針はあきらめざるを得ませんでした。
なぜならListView
がサイズ計算を頻繁に間違えてメッセージが途切れたり巨大な余白が発生したりするからです。
今振り返って思い当たる原因としては、Xamarin.Formsレンダラーの実現方法自体に問題が多かったのだと思います。仮にパクリ元であるWPFのItemsControl
でItemsSource
を指定した場合、生成されるビジュアルツリーは以下のような形になります。
ItemsControl
StackPanel
(ItemsPanel
で設定可)ContentPresenter
(ItemContainerStyle
で設定可)ItemTemplate
で指定したコンテント
ContentPresenter
ContentPresenter
一方Xamarin.Forms on UWPでは各種Renderer
自体がビジュアルツリーの要素として振る舞います。そして所定の機能を実現するために、自身もContentPresenterを使用します。結果ビジュアルツリーの階層が非常に深くなります。WPFネイティブとは異なり使用者側から直接制御できないので、ちゃんと動かないと大変なことになります。
参考までに執筆時点でもListView
を使用しているChannelDetailPage
の例を挙げておきます。上記のビューは内部に以下の要素を持っています。
<ListView>
<ListView.ItemTemplate>
<DataTemplate>
<ViewCell>
<Grid>
<Image />
<Label />
</Grid>
</ViewCell>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
このXamarin.Formsのエレメントが最終的にどのようなビジュアルツリーになるかというと...。
上位階層はUWPビルトインのテンプレートだと思われますが、ViewCell
以下の部分に何度もContentPresenter
が登場したり、Panel
が連続していたりといかにも冗長であることが分かると思います。まあ冗長だろうが重工だろうが動けばいいんですけどね。動けば。
なおこの草稿を書いている間にこれに関連したイシューが送られてきたので、その際のスクリーンショットを掲載しておきます。
これはListView
ではなくAndroid版のTableView
ですが、Xamarin.Formsでは既定値から外れるとおおむねこれぐらい胡乱な挙動に巡り合えます。
WebView
への変更
ListView
による実装をあきらめた後、アプリはWebView
にREST APIで取得したデータから生成したHTMLを流し込む方針に転換しました。上の最初のコミットではMessageWebView.Messages
プロパティが変わるたびに
Messages.set
/Messages.CollectionChanged
MessagesChanged
/Cc_CollectionChanged
RefreshMessages
WebView.Source.set
と伝播してドキュメント全体を更新していますが、そのような最悪のUXを取り続けられるはずもなくすぐにJavaScriptによる差分更新へと改善されていきます。
ただはっきり言ってWebView
での実装も苦しみの連続でした。上掲のwindow.addMessages
関数を追加するコミットではWebView
にも関わらずUWPにMessageWebViewRenderer
を追加していますが、これはXamari.Forms標準のUWP用WebView
レンダラーに WebView.Eval
で実行したスクリプトで例外が発生するとアプリごと落ちる という重篤な問題があったからです。なぜかというと標準WebViewRenderer
では
`await Control.Dispatcher.RunAsync(CoreDispatcherPriority.Normal, async () => await Control.InvokeScriptAsync("eval", new[] { eventArg.Script }));`
とWebView
で要求されたスクリプトを実行しています。C#文法に詳しくない方に解説しておくと、Control.InvokeScriptAsync
メソッドを高階関数Control.Dispatcher.RunAsync
内部で非同期に実行するという意図のコードです。UWPではコントロールにアクセスできるスレッドを制限していますので、CoreDispatcher.RunAsync
がUIスレッドへのマーシャリングを行い、UWPWebView
ネイティブのInvokeScriptAsync
を叩いているわけです。この2処理の組み合わせ自体は適切なのですが、問題はRunAsync
の返す非同期処理はスケジュールのみを行い、指定した処理の完了までは待機しないことです。したがってRunAsync
は引数の発生させた例外についてはいかなる面倒も見ません。もしInvokeScriptAsync
が例外を投げたらcatch
のチャンスを与えられずにUIスレッドが終了してしまうという非常につらい状況となっています。
ですのでkokoro-io-appではWebView.Eval
を使わず、InvokeScriptAsync
を直接呼べるメソッドを露出させてメインスレッドで実行しているのです。
余談ながらこのInvokeScriptCore
実装は残念ながらUWP以外にも伝染していきます。これはXamarin.FormsではなくAndroid SDKの仕様によるものですが、AndroidのWebView
は逆にスクリプトの評価に失敗しても例外をスローしません。Xamarin.Formsではページロードの完了を安全に把握するにはスクリプト側から通知を挙げてやるしかありませんが、当時のコードにそのような処理は含まれていなかったので、JS未読み込みに伴う例外はきちんとキャッチできる必要があったのです。上掲コミットではWebChromeClient
を差し込んでコンソールのエラーを拾うというなんとも悲しい方法で例外を把握しています。
fワードが飛び出していますが、WebView
の厳しい点はまだまだありました。kokoro-io-appではリソースから読みだされたHTMLをhttps://kokoro.io/
をベースURLとして表示しようとしています。この文字列+ベースURLでのDOM読み込みにWebView
は一応対応しているのですが、UWP実装では恐ろしいことに非表示のUWPWebView
でHTMLを解析して<base>
タグを挿入した後、正規のUWPWebView
にHTMLを流し込むアプローチをとっています(WebViewRenderer.cs
)。どのように生きてきたらこのような実装を思いつくのかは不明ですが、とりあえず諸々の不都合があるので素直にHTMLをロードできるようにレンダラーを改修しています。
着信音
せっせとワークアラウンドを投入したおかげかWebView
が落ち着いてきたので、kokoro-io-appはどうでもいい機能追加に走ります。まずは着信音です。しかし問題のコミットではなぜかAndroid実装がコメントアウトされています。Androidでオーディオを再生するにはファイルをAndroidリソースとして登録しておくか、ストレージ上にファイルを配置する必要がありますが、開発機の Xamarin.Androidでは .mp3ファイルのビルドアクションをAndroidResource
にするとビルドに失敗するのでした。ちなみにプロジェクトプロパティには.mp3リソースに関する設定が存在するので対応していないということはないはずです。
これは後に一時ファイルを介して再生可能に変更しましたが、着信音自体がものすごく不評だったので既定ではオフになっています。悲しい。
MasterDetailPage
の採用
最初にふれたとおりkokoro-io-appは当初メインページとしてTabbedPage
を採用していました。が、一瞬でタブが1個に削減されたので結局NavigationPage
を使用していました。これはスタックベースのページ遷移を提供するもので、メッセージ一覧からチャンネル一覧へは戻るボタンで遷移することになります。この箇所が不便であることは認識されていたので、メッセージ表示が充実してきた後でレイアウトをMasterDetailPage
へと切り替えることにしました。
これは主となるDetail
ページの上にスワイプで引き出せるMaster
ページを重ねるもので、2ページしか切り替えられない分操作性が向上するものです。kokoro-io-appはRoomsPage
(現MenuPage
)とMessagesPage
の2画面構成ですし、他の多くのチャットアプリやメーラーもこれに類する配置を採用しています。
しかし変更後のコードを見るとDetail
にはMessagesPage
ではなく、NavigationPage
を挟んでMessagesPage
を表示する実装となっています。これはなぜかというと、 まともに表示できるパターンがこれしかないからです。具体的にはMasterDetailPage
が提供する子ページ以外のUI要素は
Master
を表示するボタンPage.ToolbarItems
に設定された項目
ですが、NavigationPage
時代に行っていたようにチャンネル別のMessagesPage
をMaster
に設定すると、2画面目からMessagesPage
に設定されているToolbarItems
がきちんと表示されなくなるようでした。ToolbarItems
が変化するのがよろしくないのかと思ってMasterDetailPage
側にToolbarItems
を設定しても状況は改善しません。最初は鼻で笑っていた技術ブログの内容に従って中間にNavigationPage
を挟んでみるとToolbarItems
は表示されるようになったものの、今度はUWP on Windows PCでボタンが消えるなどの別の問題が発生し、どうも落としどころが見つかりません。このあたりはXamarin.Formsに習熟したユーザーも苦しんでいたようで、ググってみると複雑怪奇な「ベストプラクティス」を見ることができます。
最終的にはWindows PCではMaster
を常時表示し、MessagesPage
を再利用して画面遷移を極力減らしつつ不具合が発生しないよう祈るといった実装になりました。
新着通知
これまでに触れたとおりkokoro-io-appにはすでに新着通知機構が組み込まれていましたが、課題としてMaster
側に表示される新着をDetail
側では視認できないというものがありました。ですので短期間ですがMessagesWebView
の上にListView
をオーバーレイし、受信したメッセージのログを流すというデザインを採用していました。これにはその見た目から「洋ゲー」という通称があります。無論ListView
でMessagesWebView
の操作が阻害されるのは困りますので、ListView
にはInputTransparent
が設定してあります。
しかし、 AndroidのレンダラーはInputTransparent
を考慮していないため、上掲コミットのような対策が必要になりました。なぜ既定であるVisualElement
で定義されているプロパティをVisualElementRenderer
が考慮していないのかは謎ですが、おそらく単体テストどころか動作確認すらしていないのでしょう。
なおこの不具合はXamarin.Forms 2.4.0で修正されたらしいです。
思い出していてうんざりしてきたのでXamarin.Formsの可愛いところの紹介はこれぐらいにして、後編では使用しているサードパーティライブラリにふれていきたいと思います。