これはXamarin Advent Calendar24日目の投稿です。
今回はXamarin.Formsに新しいプラットフォーム サポートを追加するためには、どんな作業が求められているのか、調べてみたいと思って始めました(Xamarin.Formsに関してはほとんど使ったこともなく知識もあまり無いので…)。ただ、残念ながら現状動くものはほぼ何もありません(!) というわけでこれも半ば経過報告に近い内容です。いずれ更新する前提でqiitaに書くことにしました(今日も残り時間で更新するかも)。あるいは同人誌の原稿ネタにするかも。
まえがきはこのくらいにしておいて、本編に入りましょう。
Xamarin.FormsはクロスプラットフォームのUIツールキットを自称し、Android, iOS, MacOS, UWPなど実際にさまざまなプラットフォームをサポートしていますが、世の中には他にも幅広く使われているGUIツールキットやプラットフォームが存在します。わたしのデスクトップ環境はUbuntu LinuxのGNOME環境であり、ここで動作するアプリケーションは主にGtk+で書かれています。KDEであればQtで書かれたアプリケーションが動作することでしょう。また、Windowsアプリケーションであっても、UWPとして動作してほしいアプリケーションもあれば、そうでない、WPFのように通常のWindowsアプリケーションとして動作してほしいものもあるはずです。
Xamarin.Formsが現在サポートしていない環境で動作するためには、Xamarin.Formsの「プラットフォーム」実装を用意しなければなりません。今回は、新しくプラットフォームを実装してみることで、新しいプラットフォームの追加に際して、どのような作業が必要になるかを調べてみようと思いました。
今回は、わたしのデスクトップでXamarin.Formsアプリケーションを動作させることを目的として、Gtk#…ではなく、クロスプラットフォームのデスクトップGUIツールキットであるXwtを使って、これを実現するつもりで着手しました。WPFのバックエンドもあるので、うまく行けばWPFでも動作するかもしれません。
EtoやAvaloniaも検討しましたが、いずれもモバイルまで包摂しようとするGUIフレームワークなので、そうなるとむしろデスクトップ専用のAPIよりも実装に際しての自由度が低くなるのではないかという(抽象的な)懸念があり、またXwtであればXamarin.Formsと同様にプラットフォームに特化した拡張が可能なので、いざという時にGtk#に「逃げる」ことが可能であろう、という判断から、今回はXwtにしました。
Xamarin.Formsのライブラリの中心部分は、PCLで実装されているXamarin.Forms.Core.dllと、Xamarin.Forms.Platform.iOS.dll、Xamarin.Forms.Platform.Android.dll、…といったプラットフォーム別のアセンブリという構成から成っています。最近はTizenが追加されてアツい状況(?)ですね。
また、Xamarin.Formsを使用したアプリケーションは、PCLで作成できるクロスプラットフォームのGUIアプリケーション共通dll(MyAppとします)と、それをプラットフォーム別の手法で起動するプラットフォーム別のアプリケーション プロジェクト群(MyApp.iOS, MyApp.Android, …)から成っています。これらの内容は、新規Xamarin.Formsアプリケーションを作成した時に、見ることができる内容です。
というわけで、まずXamarin.Forms.Platform.Xwt.dllを追加します。
それと、アプリケーションのプロジェクトがどのような構成になるかを確認するために、ControlGalleryのプラットフォーム別実装も追加します。そのプラットフォームのプロジェクトを作成します(Xwtの場合は単なるコンソール アプリケーション)。
両方出来たら、まずアプリケーションプロジェクトのほうを作成します。アプリケーションがXamarin.Formsをブートストラップするプロセスは短いはずです。AndroidならMainActivityとしてFormsApplicationActivityの派生クラスが作成され、iOSならAppDelegateとしてFormsApplicationDelegateの派生クラスが作成されるように、そのGUIツールキットのブートストラップの手法に沿った初期化を行うようにします。
今回使用するXwtの場合、Xwt.Application.Initialize()を呼び出してから、XwtのWindowを表示するなどして、それからXwt.Application.Run()でアプリケーションループを開始します。
さて、Xamarin.Formsの方にフォーカスを移しましょう。Xamarin.Forms.Applicationクラスのインスタンスを生成しようとすると、Xamarin.Forms内部でのさまざまな初期化プロセスが走ります。これを何も考えずに行うとこうなります。
“You MUST call Xamarin.Forms.Init(); prior to using it.” と言われています。Xamarin.Forms.InitなどというAPIは実は存在しないのですが(!)、どうやらXamarin.Forms.Applicationのコンストラクタの実行中に、Device.PlatformServicesへのアクセスが発生して、このstaticプロパティが設定されていないために、例外が投げられているようです。Applicationのインスタンスを作成する前に、このIPlatformServicesの実装を設定する必要がありそうです。
もちろん、このIPlatformServicesは、プラットフォームに合わせて実装する必要があります。ようやくXamarin.Formsを実装しているという雰囲気が出てきましたね!
ところで、IPlatformServices、これを執筆中のXamarin.Forms v2.3.3の段階では、Xamarin.Forms.Core.dllに含まれる非公開インターフェースなのです。この型にアクセスできるようにするには、Xamarin.Forms.Core.dllにInternalsVisibleToAttributeを追加して(つまりXamarin.Forms.Core.dllに変更を加えて)、自分のPlatformアセンブリを指定する必要があります。実のところ、iOSやAndroidなど各プラットフォームアセンブリについて、AssemblyInfo.csの中で指定されています。
Xamarin.Formsのプラットフォーム別実装では、慣例的に、Xamarin.Forms.Formsクラス(これはCLSCompliantなネーミングでは無いのであまり良くないと思いますが)をプラットフォームDLL上で定義していて、その中でこのDevice.PlatformServicesも設定しています。Device.Info、Ticker.Defaultといった、プラットフォームに依存しそうな各種グローバル(static)プロパティも設定されています。新しいプラットフォーム向けには、既存のForms.csのいずれかを適当にコピーしつつ、似たようなInit()メソッドの実装を追加すると良いでしょう。
Forms.Init()(と慣習的に命名された初期化メソッド)では、プラットフォームに特化したIPlatformServicesの実装も必要になるので、それを他のプラットフォームから適当にコピーしてきて実装します。XwtPlatformServicesはWindowsBasePlatformServicesをベースに実装しました。最初のうちは、実装の面倒そうな部分は throw new NotImplementedException();
とでも書いておけば良いでしょう。他のメンバーも適当に実装クラスを作って設定します。
さて、ここまでやったら、アプリケーションが実行できるようになるでしょうか?
なりません。どうやらISystemResourcesProviderのDependencyServiceが必要になるようです。この問題にハマるケースは2つあって、(1) IPlatformServicesのGetAssemblies()メソッドをちゃんと実装していない場合と、(2) 対象DependencyServiceがきちんとアセンブリに定義されていない場合です。後者については、DependencyServiceの正しい使い方を知っている読者には分かると思いますが、Properties/AssemblyInfo.cs に次のような行を追加すると良いでしょう。
[assembly: Xamarin.Forms.Dependency (typeof (ResourcesProvider))]
ControlGalleryのプラットフォーム実装には、PageRendererとLabelRendererのカスタム実装が含まれています。カスタムレンダラー(実際には標準レンダラーというべきでしょうが)はプラットフォームでいずれ実装しなければならない存在なので、ここで軽く実装しておくのが良いでしょう。
カスタムレンダラーのクラス階層構造は、厳密にはプラットフォームによって異なりますが、概ね同様の構成に基づいていると言えます(メンバー構成はプラットフォームによって大きく異なります)。新しいプラットフォームでは、基本的に共通している階層構造を倣いつつ、プラットフォームに合わせたかたちで実装するのが良いでしょう。以下は今回のXwt実装における階層構造です。
namespace Xamarin.Forms.Platform.XwtBackend
{
public class LabelRenderer : ViewRenderer<Xamarin.Forms.Label, Xwt.Label>
{
}
public abstract class ViewRenderer : ViewRenderer<View, Widget>
{
}
public abstract class ViewRenderer<TView, TNativeView> : VisualElementRenderer<TView> where TView : View where TNativeView : Widget
{
}
public abstract class VisualElementRenderer<TElement> : Xwt.Widget, IVisualElementRenderer, IEffectControlProvider where TElement : VisualElement
{
// 略
}
public interface IVisualElementRenderer : IRegisterable, IDisposable
{
VisualElement Element { get; }
VisualElementTracker Tracker { get; }
Widget Widget { get; }
event EventHandler<VisualElementChangedEventArgs> ElementChanged;
SizeRequest GetDesiredSize (int widthConstraint, int heightConstraint);
void SetElement (VisualElement element);
void UpdateLayout ();
}
}
ViewElementRendererの基底クラスが、プラットフォームによって異なります(Android.Views.View, UIKit.UIView, System.Windows.Controls.Panel)が、それ以外は基本的に同様です。
Xwtでは、ここまで実装して実行すると、ようやくクラッシュせずにウィンドウが表示されるようになりました。見えにくいですが、赤い矩形で囲った部分にあります。
実装編(1)では、プリミティブなウィンドウを表示するまでの部分を、Xamarin.Formsのコードを追いかけながら実装してみました。今は、ウィンドウは表示されているものの、内容は何も表示されていない状態です。次は簡単なラベルとボタンだけでも表示できるようにしたいところです。
ところが、Xamarin.Formsの実装は高度に複雑化した構成になっていて、一筋縄ではUIを自由自在に表示することすらままならないものです。ここでは既存の実装、主にXamarin.Forms.Platform.Android.dllの実装をじっくり読み解きながら、Xwt実装でやるべきことを探っていきましょう。
前述した通り、Xamarin.Formsのプラットフォーム別実装は、クラス階層構造のレベルではある程度共通化されています。ただし、プラットフォーム間で共通の抽象クラスを使用するアプローチではなく、あくまで各プラットフォームに合わせた実装となっています。クラス階層は似通っていますが、メンバー構成は大きく異なります。
ごく簡単な、Page上にひとつのLabelが乗っているだけのUIを想定してみましょう。Xamarin.FormsのUI階層構造は、XAMLのように記述すると、次のようになっています。
<Platform>
<Platform.Page>
<ContentPage>
<ContentPage.Content>
<Label Text="foobar" ... />
</ContentPage.Content>
(以下略)
XAML「のように」と書きましたが、実のところ、ルート要素は Application
になっていませんね? プラットフォームUIレイヤーで、最上位要素として作られるのはこのPlatform
と呼ばれる部分なのです。
そして、各UI要素には、対応するrendererが存在します。ここでもそれは例外ではなく、Platformには対応するPlatformRendererが存在するのです。そして、Platformには現在のPageのインスタンスが保持されており、PageにはPageRendererがあります。後は普通に想像できる通り、Pageの内容であるLabelにも、LabelRendererが存在します。
LabelRendererは、プラットフォーム固有のUIコントロールですが、Labelを継承していません。ViewRenderer<,>というクラスを継承しており、このViewRendererは、VisualElementRenererという、直接にはプラットフォームのUIコントロールと結び付けられない基盤クラスから派生しています。このViewRendererとVisualElementRendererという階層構造は、各プラットフォーム アセンブリに共通して存在しています。
ViewElementRendererは、AndroidではFormsViewGroupという特殊なViewGroupから派生しています(これはJavaレベルでいくつかの実装を追加したライブラリに対するJavaバインディングXamarin.Forms.Platform.Android.FormsViewGroup.dllとして実装されています)。Xamarin.Forms.Labelの実装となるAndroid.Widget.TextViewやXamarin.Forms.Buttonの実装となるAndroid.Widget.Buttonは、このViewRenderer(であるFormsViewGroup)上に配置されます。
public class LabelRenderer
: ViewRenderer<Xamarin.Forms.Label, Android.Views.TextView> {...}
public class ViewRenderer<TView, TNativeView>
: ViewElementRenderer<TView> ...(snip)
where TView : Xamarin.Forms.View,
TNativeView : Android.Views.View {...}
public class ViewElementRenderer<TElement>
: FormsViewGroup, ...(snip)
where TElement : Xamarin.Forms.VisualElement {...}
PageRendererは、対応するネイティブのコントロールが無いため、VisualElementRenderから派生しています。
public class PageRenderer : VisualElementRenderer<Page> {...}
PlatformRendererは、これも例外的な存在で、これはViewRendererを継承せず、ViewGroupを直接継承しています。
public class PlatformRenderer : ViewGroup, ...(snip) {...}
いずれにしろ、ここで押さえるべきポイントは、次の3点です。
最後に、AndroidのFormsAppCompatActivityでは、OnCreate()の中でRelativeLayoutが生成されてSetContentView()で指定され、Pageが置き換えられた場合でもそのRelativeLayoutがずっと使いまわされることになります。
Xamarin.Formsは、プラットフォームのアプリケーション ループの上に、共通化された仮想的なGUIアプリケーションのレイヤーを構築するものであって、その実装はプラットフォームに根付いているものです。AndroidであればXamarin.AndroidのActivityのライフサイクルから、iOSであればXamarin.iOSのアプリケーション ループから、UWPであればUWPのApplicationのライフサイクルから、いずれも逃れられるものではなく、それぞれのやり方に則って、Formsアプリケーションとして動作します。(Xamarin.AndroidがAndroidのZygoteスレッドのライフサイクルに乗っているのと、あるいはXamarin.iOSがNSRunLoopに沿っているのと、同様であると言えます。)
それらネイティブのプラットフォームの流儀に合わせるべく存在しているのが、ネイティブのコントロールとして存在しているViewElementRendererです。
GUIアプリケーションは、どのプラットフォームでも、アプリケーション ループの最初でいきなりGUIコントロールを作成し表示してから、それぞれのプロパティを設定してUIを調整していくことはしません。アプリケーション ループの流儀に則って、ネイティブのコントロールを作成してから、そのプロパティを調整していき、またアプリケーション ループの流儀に沿ってUIオブジェクトを表示する、というのが一般的です。Xamarin.Formsのプラットフォーム実装も同様です。
AndroidのViewがどのような手順を経て表示されるのかを知るには、レイアウトのライフサイクルを知る必要があります。幸い、日本語でよくまとめられた資料が存在しています。
http://www.ecoop.net/memo/archives/android_lifecycle_of_view.html
AndroidのFormsAppCompatActivityの場合、content viewは先に軽く言及したRelativeLayoutとなりますが、この中にPlatformRendererがAddView()で追加されることになります。
(ちなみにこの部分をソースで追いかけていると、AddView(Android.Views.View)にPlatformを渡していて、「AndroidのViewじゃないのに…!?」となりますが、別のところでimplicit operatorがperatorが定義されていて、そこでPlatformRendererが返されているので、問題なくコンパイル出来るようになっています。歴史的な理由でしょうが、こういうコードを書くとソースを追うのが困難になるので、なるべくやりたくないものです。)
Activity.SetContentView()でViewを追加すると、Activityのライフサイクルに沿って、まずView.OnAttachedToWindow()が呼び出されます。その後は(Activityのライフサイクルはほぼ関係なくなり)、Viewのライフサイクルに沿って、OnMeasure()、OnLayout()が呼び出されます。OnMeasure()でまずサイズを計算し(Androidではその中でSetMeasuredDimension()でサイズを設定することが期待されています)、その結果に基づいてOnLayout()で実際のレイアウト調整を行い、その後OnDraw()で実際の描画を行う、という流れです。
プラットフォームのrendererは、Formsコントロールの値を取得してネイティブに反映するだけの、一方向的なものではありません。PlatformRenderer.OnLayout()がAndroidプラットフォーム側から(OnMeasure()の結果に基づいて)具体的なサイズ情報を渡されて呼び出されると、その中でPlatformのIPlatformLayout.OnLayout()が呼びだされ、その中からLayoutRootPage()が呼び出され、その中からXamarin.Forms.Page.Layout(Xamarin.Forms.Rectangle)が呼び出され、そこでPage.Boundsがこのサイズ情報で更新されます。このようにして、Androidプラットフォームからの情報がForms側のコントロールに伝播していきます。LayoutRootPage()を、SetMeasuredDimension()の呼び出しなしに行うと、適切なサイズが渡されずに、まともなレイアウト処理が行われなくなるでしょう。
ちなみに、OnAttachedToWindow()をオーバーライドしているコントロール(のレンダラー)は多くありませんが、たとえばPageRendererはParentがIPageControllerであればそのSendAppearing()を呼び出してページナビゲーションを処理するなどしています。(逆にOnDetachedFromWindow()ではSendDisappearing()が呼び出されます。)
Xamarin.Forms.Platform.Androidには、実は2つのForms実装Activityが存在します。ひとつが単なるAndroid.App.Activityを継承したFormsApplicationActivityで、もうひとつがAndroid.Support.V4.App.AppCompatActivityを継承したFormsAppCompatActivityです。基本的に後者がMaterial Designにも対応したモダンな実装であり、Xamarin.FormsアプリケーションのIDEテンプレートのAndroidプロジェクトのデフォルトにもなっています。
このように、ひとつのPlatformサポートの中に複数のFormsアプリケーションのブートストラップ ラインがある場合、それぞれの内容に応じて、適切なRendererを切り替えたい場合があります。たとえば、FormsAppCompatActivityでは、AppCompatButtonを使うのが適切です。このような場合、それぞれのLoadApplication()メソッドの中で、次のような呼び出しが行われます。
RegisterHandlerForDefaultRenderer(typeof(Button), typeof(AppCompat.ButtonRenderer), typeof(ButtonRenderer));
これは、[ExportRenderer]で属性で静的にレンダラーを定義する代わりに、メソッド呼び出しで動的にレンダラーを定義しています。Buttonについては、ButtonRendererへの呼び出しの代わりに、AppCompat.ButtonRendererが使われることになるわけです。
Androidサポートのソースファイルは、AppCompatと従来型の2種類があり、ソースを追いかける場合は、どちらが使われているかを意識しながら(正しいものを読んでいるか確認しながら)読み解く必要があります。
VisualElementRendererの実装は、AndroidのUIフレームワークでいえばViewに相当するような共通の基底クラスであり、プラットフォーム毎にだいたい同じ「レンダラー」の機能を実現するものとして存在しています。このクラスの中では、特に次の2つの機能が必要とされます。
ChildAdded(), ChildRemoved()などといったメソッドでUI子要素を管理するのはVisualElementPackagerというクラスです。子ビューは実際にはだいたい1つですが(LabelRendererならAndroid.Widget.TextView、ButtonRendererならAndroid.Widget.Button)、複数の内容が含まれる場合に、そのレイアウト順を保持して順次レンダリングする、などといった役割を担っています。
VisualElementTrackerは、再レイアウト処理などによって座標およびサイズへの変更があった場合に、それを実際の子ビュー群に反映する役割を担っています。実際にはクリッピング処理や回転処理、表示・非表示の切り替えなどもその作業内容に含まれます。
(VisualElementTrackerの実装の詳細としては、ViewGroup.AddOnAttachStateChangeListener()にAttachTracker.Instanceを追加しておくことで、VisualElementRendererが追加された時はそのTrackerのHandleViewAttachedToWindow()が呼び出されるようになっています。その中で、ネイティブ ビューの初期化やクリッピングを行っています。他のプラットフォームでVisualElementTrackerを実装するときも、同様の処理が必要になる、ということです。)
ここまで紹介してきた共通のUI基盤を、簡単に表にまとめておきます。
クラス | 内容 |
---|---|
Platform, PlatformRenderer | 最上位(ルート)のレンダリング要素 |
VisualElementRenderer, ViewRenderer<,> | ネイティブのコントロール(AndroidならViewGroup、iOSならUIViewController、UAPならPanelなど)から派生し、実際のUIコントロールをラップして表示する。 |
VisualElementPackager | View子要素を管理するためのクラス |
VisualElementTracker | FormsのViewの内容に変更があったときにそれをプラットフォームのレンダラーに反映する。 |
レイアウト処理の中で必ず行われるのが、Xamarin.Formsコントロールからネイティブ コントロールへの変換とサイズ計算です。これが正しく期待通りに行われないと、Xamarin.FormsのVisualElementを実際のプラットフォームにレイアウトした時に、正しく表示されないことになります。
(また、ネイティブレイアウトからのUI操作による再レイアウトなども、Xamarin.FormsのVisualElementと情報を一貫して保っていないと、レイアウトが崩れることになるでしょう。ただしこれは本節の内容を超えるので、ここでは言及にとどめます。)
サイズ計算にあたっては、必ずMeasureSpecFactory.MakeMeasureSpec()が呼びだされているであろう、という推測をもとに、その呼び出しを探してみた感じでは、以下の場合について行われます。あくまでAndroid実装についての話なので、他のプラットフォームでは様相が全く異なるかもしれません。
(1) ViewRenderer<,>.OnLayout()
これはViewGroup.OnLayout()のオーバーライドであり、Androidプラットフォームから呼び出されるメソッドです。その中では、単純に、その関連付けられたViewのMeasure()とLayout()を呼び出します。
ViewRenderer<,>.OnLayout()自体、プラットフォームから呼ばれている(すなわち既にサイズ情報を与えられているので、それがさらに関連ViewのLayout()を呼び出している、という再帰下降的なロジックになっていると言えそうです。
(2) IPlatformLayout.OnLayout()
IPlatformLayout.OnLayout()はPlatformRenderer.OnLayout()が利用していて(すなわちそれ自体はAndroidプラットフォームから呼びだされます)、実装はPlatformクラスのIPlatformLayout.OnLayout()(インターフェースメソッドの明示的実装)のみです。この中では次の3つが呼び出されています。
2.に関して、ViewElementRenderer.UpdateLayout()は、上記の他にVisualElementRenderer.OnLayout()でも使用されていますが、実際にはVisualElementTracker.UpdateLayout()を呼び出すのみです。
(3) IPlatform.GetNativeSize()
このメソッドは、主にLayoutクラスの実装から呼び出されています。
SizeRequest IPlatform.GetNativeSize(VisualElement view, double widthConstraint, double heightConstraint);
このメソッドの中では、各レンダラーで実装されているはずのGetDesiredSize()が使用されます。
SizeRequest IVisualElementRenderer.GetDesiredSize(int widthConstraint, int heightConstraint);
IVisualElementRendererのデフォルト実装と言えるVisualElementRendererでは、GetDesiredSize()の中でMeasure()を呼び出して、その結果を返しています。
GetDesiredSize()を使用しているのは、実際にはXamarin.Forms.VisualElementにあるOnSizeRequest()のみです。このメソッド自体はObsoleteとなっているのですが、実際には同クラスのOnMeasure()で使用されています。このメソッドはGetSizeRequest()で使用されており、さらにそれはMeasure()で使用されており、Measure()以外は全て公開メソッドとしてはObsoleteです。Measure()は主に各Layoutクラスで使用されています。
(余裕ができたら、この辺はもう少し全体的な流れを理解しやすいようにまとめたいところです。)
ここまで、Android実装の話に、少し踏み込みすぎたでしょうか? ただ、IVisualElementRendererを使ってビューを管理するモデルは、実はiOSでも共通の構成になっているのです。
public interface IVisualElementRenderer : IDisposable, IRegisterable {
VisualElement Element { get; }
UIView NativeView { get; }
UIViewController ViewController { get; }
event EventHandler<VisualElementChangedEventArgs> ElementChanged;
SizeRequest GetDesiredSize(double widthConstraint, double heightConstraint);
void SetElement(VisualElement element);
void SetElementSize(Size size);
}
WinRT/UWPだとこんな感じです:
public interface IVisualElementRenderer : IRegisterable, IDisposable
{
FrameworkElement ContainerElement { get; }
VisualElement Element { get; }
event EventHandler<VisualElementChangedEventArgs> ElementChanged;
SizeRequest GetDesiredSize(double widthConstraint, double heightConstraint);
void SetElement(VisualElement element);
}
どのプラットフォームでも、大体似たような、ネイティブプラットフォームとXamarin.Forms間のサイズ情報のやり取りを前提に、プラットフォームに特化した実装を書けるようになっているはずです。(その辺がドキュメント化されていると嬉しいのですが!)
現在作りかけのXwt実装では、ContentPageの中に1個のLabelを表示するものですら、やっつけコードを追加しないと表示できない状態です。時間ができたら、ちまちまとAndroid実装を追いかけながらXwtで再現できるところはやってみようと思います。 https://github.com/atsushieno/Xamarin.Forms/tree/xwt
補足: このツリー自体は、いくつか修正を加えないと使えません。
多分これでいけます。