Xamarin SKIASharp:MVVM 指南






4.27/5 (4投票s)
使用 MVVM 方式使用 SKIASharp 的画布控件的指南
引言
当我们开始学习 Xamarin 上的 .NET Framework 等编程技术时,我们会从一个知名的“Hello World”开始,然后是一个看似
当然,开始是好的,但并不有趣。我们很快就想做一些有趣的事情来展示,比如显示和操作图像,绘制几何图形等等。
我们很快找到了方法:SKIASharp
,它是一个图形库,实现了一个 Canvas
控件,允许进行所有 2D 操作:图像、几何图形、变换、路径、效果等等。
在本文中,我们将探讨如何将该控件的基本用法转换为满足 MVVM 架构需求的实现。
注意
SKIASharp
的文档非常清晰和完整,本文不再赘述。
本文还假设读者在 Xamarin .NET 编程方面具有最少的经验。
重要的是要理解掌握 MVVM 架构的步骤,尽管我们将主要集中在 ViewModel
上。
第一步
让我们开始创建一个名为 SKIatoMVVM
的项目,以及我们的第一个名为 CirclePage
的页面。
然后,我们添加 SKIA Canvas
控件,它名为 SKCanvasView
。
我们需要在 XAML 中声明一个命名空间
<ContentPage … xmlns:skia="clr-namespace:SkiaSharp.Views.Forms;
assembly=SkiaSharp.Views.Forms"…>
让我们将控件添加到页面中,我们会注意到我们通过调用将在代码隐藏中声明的 Canvas_PaintSurface
函数来响应 PaintSurface
事件。
<skia:SKCanvasView x:Name="Canvas" PaintSurface="Canvas_PaintSurface"/>
最后,在代码隐藏中,让我们实现 Canvas_PaintSurface
函数。
private void Canvas_PaintSurface(object sender, SKPaintSurfaceEventArgs e)
{
SKCanvas canvas = e.Surface.Canvas;
canvas.Clear();
SKPaint fillPaint = new SKPaint
{
Style = SKPaintStyle.Fill,
Color = new SKColor(128, 128, 240)
};
canvas.DrawCircle(e.Info.Width / 2, e.Info.Height / 2, 100, fillPaint);
}
问题
此时,我们可以看到什么问题?
- 就我个人而言,当我在 XAML 中描述用户界面时,我遵循零代码隐藏的原则,也就是说,不应该在代码隐藏中编写任何代码。
- 更重要的是,绘图渲染与页面绑定。如果我们想在另一个页面上绘制相同的图形,我们就必须复制代码。
解决方案,开始重构
让我们开始将代码转换为可重用的。
第一个反应是将 Canvas_PaintSurface
事件处理程序中的代码移到一个单独的函数中,例如,移到一个 CircleRenderer
类中,该类有一个 PaintSurface
方法。
class CircleRenderer
{
void PaintSurface(SKSurface surface, SKImageInfo info)
{
SKCanvas canvas = surface.Canvas;
canvas.Clear();
// and so on.
事件处理程序变为
private void Canvas_PaintSurface(object sender, SKPaintSurfaceEventArgs e)
{
new CircleRenderer().PaintSurface(e.Surface, e.Info);
}
为了实现零代码隐藏,我们必须删除事件处理程序本身。
为此,我们创建一个自定义控件。让我们声明一个类,例如 SKRenderView
,它继承自 SKCanvasView
控件。
现在,在 SKRenderView
中,我们可以访问 protected virtual
方法 OnPaintSurface
,我们将覆盖它。
class SKRenderView : SKCanvasView
{
protected override void OnPaintSurface(SKPaintSurfaceEventArgs e)
{
new CircleRenderer().PaintSurface(e.Surface, e.Info);
}
}
这样,我们就可以删除代码隐藏中的事件处理程序。它现在是空的。
但是,我们必须修改 XAML 以使用新控件。
让我们删除前面声明的 xmlns:skia
命名空间,并用包含新自定义控件的命名空间替换它。(这取决于您的项目)。
xmlns:ctrl="clr-namespace:SKIAToMVVM.Controls"
同时,让我们更改画布控件的描述。
<ctrl:SKRenderView x:Name="Canvas"/>
新问题
不幸的是,这个解决方案并不完全令人满意。事实上,绘图渲染现在与控件绑定。这样做,每种不同的绘图都需要创建一个新的自定义控件。
我们将要实现的目标是理想的解决方案,即在 ViewModel
中集成控件渲染绘图所需的所有信息,无论绘图是什么。
让我们更改 XAML 文件以定义我们希望如何向控件提供信息。
控件必须有一个 Renderer
属性,它将接收我们想要的渲染器对象(在我们的例子中是 CircleRenderer
)。
完成此步骤后,我们将得到以下代码
<ctrl:SKRenderView x:Name="Canvas" Renderer="{StaticResource CircleRenderer}"/>
我们必须定义资源(以及所需的命名空间,具体取决于您的项目)
xmlns:renderer="clr-namespace:SKIAToMVVM.Renderers"
<ContentPage.Resources>
<ResourceDictionary>
<renderer:CircleRenderer x:Key="CircleRenderer" />
</ResourceDictionary>
</ContentPage.Resources>
ViewModel
此时,我们有了
- 一个包含
Canvas
控件的 XAML 页面文件 - 一个空的 code-behind
- 一个负责渲染绘图的
CircleRenderer
类 - 一个作为资源的
CircleRenderer
实例 - XAML 文件中有一个错误,事实上,我们还没有在控件中定义
Renderer
属性。
为了引入 ViewModel
,我们将向 View
添加 3 个按钮。
- 一个按钮将圆的颜色设置为红色
- 一个按钮将圆的颜色设置为绿色
- 一个按钮将圆的颜色设置为蓝色
这 3 个按钮绑定到同一个命令,颜色作为命令参数传递。
<Button Text="Rouge" Command="{Binding ColorCommand}"
CommandParameter="#ff0000" Grid.Column="0" />
<Button Text="Vert" Command="{Binding ColorCommand}"
CommandParameter="#00ff00" Grid.Column="1" />
<Button Text="Bleu" Command="{Binding ColorCommand}"
CommandParameter="#0000ff" Grid.Column="2" />
让我们创建 ViewModel
类 CirclePageModel
。
它将包含
- 一个类型为
Command
的ColorCommand
属性以及execute
函数ExecuteColorCommand
。在这种情况下,CanExecute
函数是可选的。 - 一个类型为
CircleRenderer
的Renderer
属性
ColorCommand = new Command(ExecuteColorCommand);
ExecuteColorCommand
函数接收颜色作为字符串,我们从此值创建 SKColor
对象并分配给 Renderer
。
void ExecuteColorCommand(object colorArgument)
{
SKColor color = SKColor.Parse((string)colorArgument);
Renderer.FillColor = color;
}
我们仍然需要在 CircleRenderer
类中创建 FillColor
属性并使用它。
public SKColor FillColor { get; set; } = new SKColor(160, 160, 160);
public void PaintSurface(SKSurface surface, SKImageInfo info)
{
…
SKPaint fillPaint = …
Color = FillColor
…
}
让我们将 CirclePageModel
绑定到页面。
xmlns:model="clr-namespace:SKIAToMVVM.ViewModels"
<ContentPage.BindingContext>
<model:CirclePageModel />
</ContentPage.BindingContext>
完成控件
XAML 文件中还有一个错误需要纠正。我们写了
Renderer="{StaticResource CircleRenderer}"
但 Renderer
属性在 CircleRenderer
类中不存在,我们只需要创建它
public Renderers.CircleRenderer Renderer { get; set; }
我们可以执行应用程序,可以看到一个灰色的圆(FillColor
的默认值)。
当我们单击按钮时,什么都没有发生。
我们注意到当我们设置断点时,按钮正在工作。
那么,发生了什么?
原因很简单:在 XAML 文件中,我们将 Renderer 控件的属性绑定到一个资源,因此我们实际上有两个 CircleRenderer
实例,一个由控件知道,另一个由 ViewModel
知道。
那么该怎么做?
我们必须修改 SKCanvasView
控件的 Renderer
属性,使其可绑定。
为此,我们只需添加所需的代码。
- 添加一个可绑定属性的属性描述。
- 更改
Renderer
属性,目前我们有一个自动属性,我们将其转换为完整属性,以便能够从BindableObject
基类调用GetValue
和SetValue
方法。
// 1. Add a property description for bindable property.
public static readonly BindableProperty RendererProperty = BindableProperty.Create(
nameof(Renderer),
typeof(Renderers.CircleRenderer),
typeof(SKRenderView),
null,
// 2. Change the Renderer property
public Renderers.CircleRenderer Renderer
{
get { return (Renderers.CircleRenderer)GetValue(RendererProperty); }
set { SetValue(RendererProperty, value); }
}
在 XAML 文件中,我们替换
Renderer="{StaticResource CircleRenderer}"
有了
Renderer="{Binding Renderer}"
并且我们删除现在未使用的资源。
<renderer:CircleRenderer x:Key="CircleRenderer" />
让我们执行并单击颜色更改按钮。我们再次注意到什么都没有发生。
这是正常的,我们没有通知控件刷新自己。
我们希望在渲染器中更改颜色时通知控件刷新自己。
如何做到?
要刷新控件,我们调用控件的 InvalidateSurface
方法。
但是,我们在 ViewModel
或 Renderer
中都无法访问控件,这也是正常的。控件必须在 View
外部保持未知。
解决方案很简单:对象如何通知更改?显然是使用事件。
让我们在 CircleRenderer
类中创建事件。
public event EventHandler RefreshRequested;
我们必须在 FillColor
属性中引发事件,让我们将自动属性转换为完整属性。
SKColor _fillColor = new SKColor(160, 160, 160);
public SKColor FillColor
{
get => _fillColor;
set
{
if (_fillColor != value)
{
_fillColor = value;
RefreshRequested?.Invoke(this, EventArgs.Empty);
}
}
}
控件必须附加事件,所以更改 SKRenderView
控件
- 更改
Renderer
属性声明并添加propertyChanged
参数。 - 实现
RendererChanged
函数。 - 最后,实现
RefreshRequested
事件处理程序。
// 1. Change the Renderer property declaration and add the propertyChanged parameter.
public static readonly BindableProperty RendererProperty = BindableProperty.Create(
nameof(Renderer),
typeof(Renderers.CircleRenderer),
typeof(SKRenderView),
null,
defaultBindingMode: BindingMode.TwoWay,
propertyChanged: (bindable, oldValue, newValue) =>
{
((SKRenderView)bindable).RendererChanged(
(Renderers.CircleRenderer)oldValue, (Renderers.CircleRenderer)newValue);
});
// 2. Implement the RendererChanged function.
void RendererChanged(Renderers.CircleRenderer currentRenderer,
Renderers.CircleRenderer newRenderer)
{
if (currentRenderer != newRenderer)
{
// detach the event from old renderer
if (currentRenderer != null)
currentRenderer.RefreshRequested -= Renderer_RefreshRequested;
// attach the event to new renderer
if (newRenderer != null)
newRenderer.RefreshRequested += Renderer_RefreshRequested;
// refresh the contrl
InvalidateSurface();
}
}
// 3. Finally, implement the RefreshRequested event handler.
void Renderer_RefreshRequested(object sender, EventArgs e)
{
InvalidateSurface();
}
最终化
还有一件不太令人满意的事情需要修改。事实上,在控件中,我们的 Renderer
属性的类型是 CircleRenderer
。这绝对不是通用的:如果我们想绘制图像或矩形,拥有一个名为 CircleRenderer
的渲染器是不合适的。
为了解决这个问题,我们可以
- 要么使用一个基类来存放我们的渲染器
- 要么使用一个接口
我们也可以结合这两种方法,有一个实现接口的基类。
我们将只使用一个接口并称之为 IRenderer
。我们从 CircleRenderer
类中提取接口并得到
interface IRenderer
{
void PaintSurface(SKSurface surface, SKImageInfo info);
event EventHandler RefreshRequested;
}
在 SKRenderView
控件中,我们将所有对 CircleRenderer
的引用替换为 IRenderer
。
在 ViewModel
中,类型为 CircleRenderer
的 Renderer
属性仍然可以被 IRenderer
替换。程序员有责任知道他在 ViewModel
中需要什么。
结论
我们刚刚了解了如何将一个僵化且不可重用的代码结构转换为一个遵循 MVVM 模型的结构。
我们获得了多项优势
- 页面中不再有 code-behind
- 我们有了可重用的代码
- 代码结构遵循 MVVM 模型
- 每个对象的职责都分配正确
- 页面显示控件。
- SKIA 控件是绘图的载体。
- 渲染器绘制图形。
ViewModel
知道要绘制什么。
感谢阅读。
历史
- 2019 年 10 月 8 日:初始版本
- 2019 年 10 月 17 日:重新上传源代码 zip 文件,链接已损坏