SkiaSharpがXamarin.MacでもWPFでも動くよという話
Xamarin その1 Advent Calendar 2017 - Qiitaの22日目です。
去年のアドベントカレンダーでXamarin.Forms+SkiaSharpで縦組みの記事を書いたので、今回はこれを.NET Standard化してAndroidとiOSとXamarin.MacとWPFで動かしてみたよというお話です。
作成したサンプルはGitHubに置いたのでよければビルドしてみてください。
SkiaSharpとは
SkiaSharpとはC#と.Netで使用できるオープンソースの2Dグラフィックスのライブラリです。
現在はPCLと以下のプラットフォームに対応しています。
- .NET Core / .NET Standard 1.3
- Xamarin.Android
- Xamarin.iOS
- Xamarin.tvOS
- Xamarin.Mac
- Windows Classic Desktop (Windows.Forms / WPF)
- Windows UWP (Desktop / Mobile / Xbox / HoloLens)
こちらに詳しく書かれています。 qiita.com
去年のプロジェクト構成
1年前はSkiaSharpがSharedプロジェクトのみに対応で、PCLプロジェクトに対応していなかったので途中で対応してたけど書き換える能力がなかったので、XAML用に専用のカスタムビュー(TategumiView)を作成し、カスタムレンダラーをiOSとAndroidにそれぞれゴリゴリ書いて実装しました。
今回のプロジェクト構成
現在のSkiaSharpはPCLプロジェクトにも対応しているので、専用のNativeViewを作る必要がなくなり(カスタムレンダラーが不要になり)XAMLでSkiaSharp標準のビューコントローラ(SKCanvasView)を使って描画まで実装が可能です。Xamarin.Mac / WPFも同様にSkiaSharp標準のビューコントローラが用意されています。
さらに.NET Standardに対応しているので描画ロジックなどを.Net Standard対応のライブラリ化をすることでXamarin.MacやWPFからも共通のライブラリとして使用することができます。
去年のSharedプロジェクトに比べてかなりシンプルな構成になっています。
.NET Standard2.0とは
.NET Standardの詳しい説明は賢者の方々のブログを見ていただくとして、僕の唯一の理解は
NET Standard2.0 は .NET Core 2.0 と .NET Framework 4.6.1 と Mono と Xamarin.iOS と Xamarin.Android と Xamarin.Mac と UWP を(ほぼ)サポートしている(ただし、OS やランタイム依存の処理がある場合を除く)
ということです。
.NET Framework4.5以降と.net standard1.xが共存できるということに関してはよくわかりません勉強中です。
要するに.NET Standardは複数のOSでソースを共有できる仕組みなので、 この仕組みのおかげで最近のXamarin.Formsや.NET Framework4.6.1以上のXamarin.MacやWPFから.NET Standard2.0のライブラリを使用することができるというわけです。
「ほぼ」とあるのは.Net Standard2.0で作成したライブラリで例えば.NET Framework4.6.1のライブラリを参照すると完全な互換性がない可能性があると警告が表示されるのでそういうことです。
warning NU1701: パッケージ 'xxxxx 2.0.1' はプロジェクトのターゲット フレームワーク '.NETStandard,Version=v2.0' ではなく '.NETFramework,Version=v4.6.1' を使用して復元されました。このパッケージは、使用しているプロジェクトとの完全な互換性がない可能性があります。
僕の数少ない経験上では完全に互換性があります。
各プロジェクトのポイント
各プロジェクト作成にあたり一番特筆すべきは.NET Starndard2.0対応のために.csprojファイルを編集する必要がない!(2017.12.21時点)ということです。
参考までに現在のバージョンを記載しておきますが、少なくともこのバージョン以降であればIDEが提供するプロジェクト作成の手順だけで作成できます。
- Visual Studio 2017 - Ver 15.5.2
- Visual Studio For Mac - Ver 7.3.2
.NET Standard2.0ライブラリ
UIはXamarin.Forms / Xamarin.Mac / WPFそれぞれで作る必要がありますが、反対にそれ以外に関しては(上記条件を満たせば)共通の .NetStandard2.0ライブラリとしてまとめられるということになります。
今回のサンプルでは(フォントファイルなどリソースの取得に関する箇所を除く)描画ロジックをすべてをライブラリ化して、Xamarin.Forms / Xamarin.Mac / WPFから使用しています。
作成方法はとても簡単で、プロジェクト作成→.NET Standard→クラスライブラリ(.NET Standard)から作成できます。
Xamarin.Formsプロジェクト
プロジェクト作成に関しては特筆すべきことはありません。プロジェクト作成から、Cross-Platform→Cross-Platform App(Xamarin Forms)→.NET Standardを選択で作成できます。
nugetからSkiaSharpとSkiaSharp.Views.Formsを取得し、XAMLにSKCanvasViewを配置します。
<views:SKCanvasView x:Name="canvas" PaintSurface="OnPaintSample"/>
SKCanvasViewのPaintSurfaceのイベントハンドラに描画処理を記述します。
void OnPaintSample(object sender, SKPaintSurfaceEventArgs e) { // SKCanvasを取得する var canvas = e.Surface.Canvas; // 描画処理 DrawText(canvas); }
Xamarin.Macプロジェクト
Visual Studio For Macで、Macアプリ→Cocoaアプリを選択します。
SkiaSharpを使用する場合でのはまりどころとしては、ターゲットフレームワーク(オプション→全般)をXamarin.Mac Modernを選択するということです。 .Net Framework4.6.1を選択すると、パッケージの取得でWindowsと認識してしまうのか(SkiaSharp.Mac.dllではなく)SkiaSharp.WPF.dllを持ってきてしまうので(仕組みがよくわからないので誰か教えてください)。
nugetからSkiaSharpとSkiaSharp.Viewsを取得します。
Storyboardを使用してSKCanvasViewを配置してプロパティを関連付けます。
- Main.Storyboardをダブルクリック(XCodeが開く)
- ViewControllerにCustomViewを配置する
- CustomClassをSKCanvasViewにする
- 右上のAssitant editorを開く
- 作成したCustomViewをCtrlキーを押しながらViewController.hにドラッグ&ドロップする
- Nameをcanvasにする
上記作業後にXCodeを保存するとコードビハインド(ViewController.designer.cs)にコントロールとプロパティが関連付けされていることがわかります。
[Register ("ViewController")] partial class ViewController { [Outlet] SkiaSharp.Views.Mac.SKCanvasView canvas { get; set; } void ReleaseDesignerOutlets () { if (canvas != null) { canvas.Dispose (); canvas = null; } } }
Xamarin.Forms同様に、ViewController.csにイベントハンドラを記述します。
// 画面が初期化される時に呼ばれる public override void ViewWillAppear() { base.ViewWillAppear(); // SKCanvasViewにイベントハンドラを関連付ける canvas.PaintSurface += OnPaintCanvas; } private void OnPaintCanvas(object sender, SKPaintSurfaceEventArgs e) { // SKCanvasを取得する var canvas = e.Surface.Canvas; // 描画処理 DrawText(canvas); }
Storyboardの操作方法などXamarin.Macのことが詳しく書かれているEssential Xamarinをぜひご一読ください。
WPFプロジェクト
Windowsクラッシックデスクトップ→WPFアプリ(.NET Framework)→.NET Framework4.6.1以上で作成します。
XAMLへの記述以降はほとんどXamarin.Formsと同じです(クラス名が少し違うので注意)。 nugetからSkiaSharpとSkiaSharp.Viewsを取得し、XAMLにSKElementを配置します。
<views:SKElement x:Name="canvas" PaintSurface="OnPaintSample"/>
SKCanvasViewのPaintSurfaceのイベントハンドラに描画処理を記述します。
void OnPaintSample(object sender, SKPaintSurfaceEventArgs e) { // SKCanvasを取得する var canvas = e.Surface.Canvas; // 描画処理 DrawText(canvas); }
画面キャプチャ
最後に今回作成したサンプルの画面キャプチャをのせておきます。
- Xamarin.Mac
最後に
.NET Standard2.0がより一般的になることでWinFormsなどのレガシーなC#の資産をXamarinやMacで再利用する流れがそろそろより一層広がればいいなと思います。
今後の課題として、(今回はすべてコードビハインドで書いていますが)WPFやXamarinでPrismやReactivePropertyなどのフレームワークを使用した場合に、MacはCocoa Bindingなど別のフレームワークを利用することになるので、全体としてどういう構成(落としどころ)が望ましいのか引き続き勉強したいです。
以上です。 今宵もハッピーアドベント!(言わない?)
追伸、 あ!サンプルのボタンがどれも機能してないです(汗。随時実装していきますので。
明日は tafuji さんです。よろしくお願いします!
Xamarin.Macでアプリ起動時にコードでStoryboardを切り替える方法
Xamarin.Macの起動時に複数のストーリーボードからコードで選択する方法がわからなかったのでそのメモ。
デフォルトで起動時に表示されるインターフェイスはinfo.plistのMain Interfaceで指定されています(さらにInterface Builderの[is Initial Controller]にチェックが入っているWindow Controllerが表示対象)。
この起動時に表示されるインターフェイスをコードで変更する方法です。
そもそもストーリーボードの使い方がよくわかってない*1のでストーリーボードの追加方法から。
ストーリーボードを追加する
- ファイル→新しいファイル→ストーリーボードでSub.storyboardを作成する
- 作成したSub.storyboardをInterfaceBuilderで開いてWindowControllerを追加する
- InterfaceBuilderでWindowControllerを選択→IdentityInspectorを選択→StoryboardIDにMainWindowと入力する
- 保存する
public override void DidFinishLaunching(NSNotification notification) { // 呼んじゃだめ!! //base.DidFinishLaunching(notification); // StoryBoardを呼び出し var storyboard = NSStoryboard.FromName("Sub", null); // StoryBoardからViewControllerを呼び出し var controller = storyboard.InstantiateControllerWithIdentifier("MainWindow") as NSWindowController; // 表示 controller.ShowWindow(this); }
以上っす。
*1:Ctrlキー押しながらdragとか無理っと指つりながら作業してます。
*2:インテリセンスで入力するとbase.DidFinishLaunching()も追加されるけどこれは呼んじゃだめ(「陰」のP24参照)
*3:コードは Working with Storyboards - XamarinのLoading from codeそのまま。
iOSのSimulatorからParallels越しのVisualStudioで起動したASP.NETを参照する方法
バックエンドのテストをしていて、Parallels越しのWindowsのVisualStudioで立ち上げたASP.NET(IIS)を、XamarinStudioでビルドしたiOSアプリから参照する方法がわからなかったので調べてみました。
VisualStudioでASP.NETを起動するとローカルの任意のポート(例:http://localhost:10897/)で実行されます。 このポートはWindowsの外側からは参照できないのでMac側のiOSのSimulator(やAndroidのEmulator)からは使用することができません。そこでSharpProxyというソフトを使ってポート番号を切り替える(すり替える?)ことで実現しています。以下その手順になります。
- WIndows側でSharpProxyをクローンしてVisualStudioでビルド&起動します。
git clone https://github.com/jocull/SharpProxy.git
Windows側のVisualStudioでASP.NETを起動してポート番号を確認します(例:http://localhost:10897/)。
WindowsのIPアドレスをipconfigで調べます(例:172.xx.xx.xx)。SharpProxyでも[Your IP Address]で表示されています。
SharpProxyの[InternalPort]に起動したASP.NETのポート番号(10897)を入力して[Start]をクリックします。
- iOSのプロジェクトにWindowsのIPアドレスとSharpProxyの[ExternalPort](5000)を入力してビルドします(例:http://172.xx.xx.xx:5000 )
これでiOS側からParallels越しのWindowsのASP.NETを参照できます。
他にもっとスマートな方法があればぜひ教えて下さい。
追伸: Xamarinはいいぞ!!(今回はXamarinに限った話じゃないけど)
Xamarin.Formsで縦組み
Xamarin Advent Calendar 2016(その2)の21日目です。
はじめに
10月にJXUGC #17 お前の Xamarin アプリを見せてみろ!に登壇させていただいたのでそのまとめです。
goo.gl
なぜXamarin.Formsで縦組みか
最近のHTML+CSSは縦組み機能が充実しており大抵のブラウザで表示可能です。ただしブラウザによってその実装はまちまちで、端末に依存することなく同じイメージで表示したいというのが始めたきっかけです。自分で実装することで例えば左にルビを表示する(そんな人はほとんどいないでしょうけど)など自由に機能追加できることが(当たり前ですが)魅力の一つです。
SkiaSharp
縦組みの描画は2Dの描画フレームワークのSkiaSharpを使用しています。Xamarinが推奨してるわりに日本語情報は少ないのですが、最近になって記事も増えてきたので今後に期待しています。今回のサンプルではRendererを使ってAndroidとiOSをそれぞれ実装していますが、最新バージョンではXamarin.Formsに対応しています。ということで早く書き直さないと(汗)。
日本語フォント
日本語フォントを表示するためにIPAのIPAex明朝フォントを使用しています。サイトからダウンロードしたipaexm.ttfをリソースフォルダ(AndroidはAssetsフォルダ、iOSはResourcesフォルダ)へ追加しています。
縦組みエンジン(Hanako)
タグの読み込みと組版(Compose)はHanakoという縦組みエンジンで実装しています。この中で行末の折り返しやページごとに組版をおこなうといった処理をおこなっています。
縦組み描画コントロール(TategumiView)
組版されたページをTategumiViewコントロールで表示しています。SkiaSharpで用意されたCanvasにDrawTextで1文字ずつXYを指定して描画しています。
//文字の描画 static void drawHonbunChar(SKCanvas canvas, SKPaint paint, WaterTrans.TypeLoader.TypefaceInfo tfi, HKWaxBase ch) { //文字をGlyphIdに変換 var glyphs = stringToVerticalGlyphs(ch.Char, paint, tfi); //描画の文字サイズを設定 paint.TextSize = ch.FontSize; //本文を描画 drawText(canvas, glyphs, ch.DevX, ch.DevY, paint); } static unsafe void drawText(SKCanvas canvas, ushort[] glyphs, float x, float y, SKPaint paint) { paint.TextEncoding = SKTextEncoding.GlyphId; fixed (ushort* p = glyphs) { canvas.DrawText((IntPtr)p, glyphs.Length * 2, x, y, paint); } }
縦組み用の字形
上記の処理でstringやcharではなくGlyphIdで描画していますが、拗促音(ゃゅょっ)や括弧は縦組み用の字形に切り替える必要があり、これは@espresso3389さんが作った(というかこのために作ってもらった)WaterTrans.TypeLoaderを使用していて、ここでstringをGlyphIdに変換しています。
//縦組み用の文字に変換している static ushort[] stringToVerticalGlyphs(string text, SKPaint paint , WaterTrans.TypeLoader.TypefaceInfo typefaceInfo) { ushort[] glyphs; paint.Typeface.CharsToGlyphs(text, out glyphs); var conv = typefaceInfo.GetVerticalGlyphConverter(); for (int i = 0; i < glyphs.Length; i++) { if (conv.CanConvert(glyphs[i])) glyphs[i] = conv.Convert(glyphs[i]); } return glyphs; }
XAML
作成したTategumiViewをXAMLで配置してます。WidthやHeightをBindingしていてViewの向きが変わると再処理(Compose)→描画(View)処理がおこなわれます。
<local:TategumiView VerticalOptions="FillAndExpand" HorizontalOptions="FillAndExpand" Width="{Binding TateviewWidth.Value}" Height="{Binding TateviewHeight.Value}" CurrentPage="{Binding CurrentPage.Value}" PageIndex="{Binding Path=PageIndex.Value,Mode=TwoWay}">
データ形式
HTML形式で<p>タグと<ruby>タグを読み込むようにしています。タグのパースはHtmlAgilityPackを使用しています。
<p><ruby>私<rt>わたくし</rt></ruby>はその人を...</p>
サンプルデータ
青空文庫から直接スクレイピングしようとしたのですが、文字コードやタグの使い方が統一されていなかったり(HtmlAgilityPackのPCLではXPATHが使えなかったり)できれいに取得できなかったので、元のHTMLからルビと本文だけのHTMLに加工しました。作成したHTMLリソースフォルダ(AndroidのAssetフォルダ、iOSのResoucesそれぞれ)に置いて読み込む形になっています。
表示!!
目次から書名をタップするとこんな感じで表示されます。左右のタップでページ移動します。
GitHub
というわけで(不十分なところが多々ありますが)今回の縦組みサンプルをGitHubに上げました(現在Android版のみ動作します。iOSでなぜか動かないので調査中です)。奇特な方々の優しいダメだしを頂ければ大変幸いです。
最後に
今年もXamarinおよびXamariの関係者の方々と楽しく時間を過ごせました。ありがとうございました。来年もより一層よろしくお願いします。よいお年を。
.Net Framework2.0から4.6.1に上げたけどSystem.BadImageFormatExceptionで動かない
昔のプログラムを.Net Framework2.0から4.6.1に上げたけどSystem.BadImageFormatExceptionとかで動作しない。
下記をチェックしたけどどれも該当しない。
例外のトラブルシューティング : System.BadImageFormatException
わかったことはメインのexeのapp.configに
<supportedRuntime version="v2.0.50727"/>>
とあったのでこれを下記に書き換えたら動いた。
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5"/>
すっかりはまってしまった。
DbSetのAddをMockでTestする
書きなれないブログでタイトルからつまづいてますが。。。
EF6をServiceとMockを使っていい感じでテスト&モッキューできないかなと。
元ネタはなかじさんのブログ。
ここの記事にあるEFのTestをMoqを使ってやる記事があります。
この中の「Testing query scenarios」が、サンプルデータをServiceを通してGetAllBlogsで取得するとorder byされて返ってくるというTESTです。
[TestMethod] public void GetAllBlogs_orders_by_name() { var data = new List<Blog> { new Blog { Name = "BBB" }, new Blog { Name = "ZZZ" }, new Blog { Name = "AAA" }, }.AsQueryable(); var mockSet = new Mock<DbSet<Blog>>(); mockSet.As<IQueryable<Blog>>().Setup(m => m.Provider).Returns(data.Provider); mockSet.As<IQueryable<Blog>>().Setup(m => m.Expression).Returns(data.Expression); mockSet.As<IQueryable<Blog>>().Setup(m => m.ElementType).Returns(data.ElementType); mockSet.As<IQueryable<Blog>>().Setup(m => m.GetEnumerator()).Returns(0 => data.GetEnumerator()); var mockContext = new Mock<BloggingContext>(); mockContext.Setup(c => c.Blogs).Returns(mockSet.Object); var service = new BlogService(mockContext.Object); var blogs = service.GetAllBlogs(); Assert.AreEqual(3, blogs.Count); Assert.AreEqual("AAA", blogs[0].Name); Assert.AreEqual("BBB", blogs[1].Name); Assert.AreEqual("ZZZ", blogs[2].Name); }
次にServiceでAddBlogすると追加されるTestをおこなうために下記を追加。
ここでなぜか(当然?)4件にならない、ということで本題に入ります。
service.AddBlog("CCC", "http://blogs.msdn.com/adonet"); var blogs = service.GetAllBlogs(); Assert.AreEqual(3, blogs.Count); //4件にならない
ServiceでAddBlogしたときに追加されるようにしたいわけですが、見つけたのが下記のAnswerの箇所。
要するに、DbSetでAddされる時のMockがないので追加されないとのこと。
...Callbackがよくわからないのでちょっと勉強してから。
というわけで試行錯誤した結果一番最後の行ができたやつ。
//コピペのままではコンパイルされず //mockSet.Setup(m => m.Add(It.IsAny<Blog>())).Callback(blog => data.Add(blog)); //IQueryrableにAddはない //mockSet.Setup(m => m.Add(It.IsAny<Blog>())).Callback<Blog>(blog => data.Add(blog)); //AddがないのでConcatで配列を作り直す→なぜかダメ //mockSet.Setup(m => m.Add(It.IsAny<Blog>())).Callback((Blog blog) => data = data.Concat<Blog>(new[] { blog })) //ListとIQueryableを上で使い分ける mockSet.Setup(d => d.Add(It.IsAny<Blog>())).Callback<Blog>((s) => source.Add(s));
動いたソースを一応全部のっけておきます。
[Test] public void GetAllBlogs_with_mock() { var source = new List<Blog> { new Blog { Name = "BBB" }, new Blog { Name = "ZZZ" }, new Blog { Name = "AAA" }, }; var data = source.AsQueryable(); var mockSet = new Mock<DbSet<Blog>>(); mockSet.As<IQueryable<Blog>>().Setup(m => m.Provider).Returns(data.Provider); mockSet.As<IQueryable<Blog>>().Setup(m => m.Expression).Returns(data.Expression); mockSet.As<IQueryable<Blog>>().Setup(m => m.ElementType).Returns(data.ElementType); mockSet.As<IQueryable<Blog>>().Setup(m => m.GetEnumerator()).Returns(data.GetEnumerator()); //DbSetのAddをモックする //ListとIQueryableを上で使い分ける(これは仕方ない?ここをもうちょいきれいにしたい) mockSet.Setup(d => d.Add(It.IsAny<Blog>())).Callback<Blog>((s) => source.Add(s)); var mockContext = new Mock<BloggingContext>(); mockContext.Setup(c => c.Blogs).Returns(mockSet.Object); var service = new BlogService(mockContext.Object); var blogs = service.GetAllBlogs(); Assert.AreEqual(3, blogs.Count,"追加前"); Assert.AreEqual("AAA", blogs[0].Name); Assert.AreEqual("BBB", blogs[1].Name); Assert.AreEqual("ZZZ", blogs[2].Name); //もう一件追加する service.AddBlog("CCC", "http://blogs.msdn.com/adonet"); blogs = service.GetAllBlogs(); Assert.AreEqual(4, blogs.Count, "追加後"); Assert.AreEqual("AAA", blogs[0].Name); Assert.AreEqual("BBB", blogs[1].Name); Assert.AreEqual("CCC", blogs[2].Name); Assert.AreEqual("ZZZ", blogs[3].Name); }
で、下記に回答がありましたというオチです。
追伸、 もっきゅーもっきゅーって居酒屋のメニューにしか聞こえないのは自分だけ?
Bitmapとbyte[]の変換
いざ書くといつも忘れてしまうので。
画像ファイル(Bitmap)からbyte[]に変換
Bitmap bmp = new Bitmap(画像のPATH); //using System.Drawing MemoryStream ms = new MemoryStream(); bmp.Save(ms,ImageFormat.Png); //using System.Drawing.Imaging;
byte[]から画像ファイル(Bitmap)に変換
MemoryStream ms = new MemoryStream(byte[]のデータ); Bitmap bmp = new Bitmap(ms); ms.Close(); bmp.Save(画像のPATH);
byte[]って何者かいまだによく知らない。