kokoro.ioアプリが引っかかったXamarin.Formsの罠を振り返る (中編)

by pgrho 2017-12-08 00:00

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を解析し、MessageContentRendererRichTextBoxSpannableStringBuilderなどのプラットフォーム別APIでせっせとリッチテキストをくみ上げていたわけですが、このネイティブコントロールをボトムアップで強化していく方針はあきらめざるを得ませんでした。

なぜならListViewがサイズ計算を頻繁に間違えてメッセージが途切れたり巨大な余白が発生したりするからです。

今振り返って思い当たる原因としては、Xamarin.Formsレンダラーの実現方法自体に問題が多かったのだと思います。仮にパクリ元であるWPFのItemsControlItemsSourceを指定した場合、生成されるビジュアルツリーは以下のような形になります。

  • 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のエレメントが最終的にどのようなビジュアルツリーになるかというと...。

Visual Tree of ChannelDetailPage

上位階層はUWPビルトインのテンプレートだと思われますが、ViewCell以下の部分に何度もContentPresenterが登場したり、Panelが連続していたりといかにも冗長であることが分かると思います。まあ冗長だろうが重工だろうが動けばいいんですけどね。動けば。

なおこの草稿を書いている間にこれに関連したイシューが送られてきたので、その際のスクリーンショットを掲載しておきます。

ChannelsView

これはListViewではなくAndroid版のTableViewですが、Xamarin.Formsでは既定値から外れるとおおむねこれぐらい胡乱な挙動に巡り合えます。

WebViewへの変更

ListViewによる実装をあきらめた後、アプリはWebViewにREST APIで取得したデータから生成したHTMLを流し込む方針に転換しました。上の最初のコミットではMessageWebView.Messagesプロパティが変わるたびに

  1. Messages.set / Messages.CollectionChanged
  2. MessagesChanged / Cc_CollectionChanged
  3. RefreshMessages
  4. 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へと切り替えることにしました。

MasterDetailPage

これは主となるDetailページの上にスワイプで引き出せるMasterページを重ねるもので、2ページしか切り替えられない分操作性が向上するものです。kokoro-io-appはRoomsPage(現MenuPage)とMessagesPageの2画面構成ですし、他の多くのチャットアプリやメーラーもこれに類する配置を採用しています。

しかし変更後のコードを見るとDetailにはMessagesPageではなく、NavigationPageを挟んでMessagesPageを表示する実装となっています。これはなぜかというと、 まともに表示できるパターンがこれしかないからです。具体的にはMasterDetailPageが提供する子ページ以外のUI要素は

  1. Masterを表示するボタン
  2. Page.ToolbarItemsに設定された項目

ですが、NavigationPage時代に行っていたようにチャンネル別のMessagesPageMasterに設定すると、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をオーバーレイし、受信したメッセージのログを流すというデザインを採用していました。これにはその見た目から「洋ゲー」という通称があります。無論ListViewMessagesWebViewの操作が阻害されるのは困りますので、ListViewにはInputTransparentが設定してあります。

しかし、 AndroidのレンダラーはInputTransparentを考慮していないため、上掲コミットのような対策が必要になりました。なぜ既定であるVisualElementで定義されているプロパティをVisualElementRendererが考慮していないのかは謎ですが、おそらく単体テストどころか動作確認すらしていないのでしょう。

なおこの不具合はXamarin.Forms 2.4.0で修正されたらしいです。

思い出していてうんざりしてきたのでXamarin.Formsの可愛いところの紹介はこれぐらいにして、後編では使用しているサードパーティライブラリにふれていきたいと思います。