对 SkiaSharp 图形进行动画处理的应用程序通常以固定速率调用
InvalidateSurface
SKCanvasView
,通常每隔 16 毫秒调用一次。 使图面失效会触发对处理程序的
PaintSurface
调用以重绘显示。 由于视觉对象每秒重绘 60 次,因此它们似乎具有流畅的动画效果。
但是,如果图形过于复杂,无法在 16 毫秒内呈现,动画可能会变得抖动。 程序员可能会选择将刷新率降低到每秒 30 次或 15 次,但有时甚至还不够。 有时图形非常复杂,以至于无法实时呈现。
一种解决方案是通过在一系列位图上呈现动画的各个帧来事先准备动画。 若要显示动画,只需按顺序每秒 60 次显示这些位图。
当然,这可能有很多位图,但这就是制作大预算3D动画电影的方式。 3D 图形过于复杂,无法实时呈现。 渲染每个帧需要大量的处理时间。 watch影片时看到的实质上是一系列位图。
可以在 SkiaSharp 中执行类似操作。 本文演示两种类型的位图动画。 第一个示例是 Mandelbrot Set 的动画:
第二个示例演示如何使用 SkiaSharp 呈现动态 GIF 文件。
曼德尔布罗特集在视觉上引人入胜,但冗长。 (有关 Mandelbrot 集和此处使用的数学的讨论,请参阅从第 666 页开始 创建移动应用Xamarin.Forms 的第 20 章 。以下说明假定背景知识。)
Mandelbrot 动画 示例使用位图动画来模拟 Mandelbrot 集中固定点的连续缩放。 放大后是缩小,然后循环将永久重复或直到程序结束。
程序通过创建最多 50 个位图来准备此动画,这些位图存储在应用程序本地存储中。 每个位图包含复杂平面宽度和高度的一半作为上一个位图。 (在程序中,这些位图表示整 型缩放级别 。) 位图随后按顺序显示。 对每个位图的缩放进行动画处理,以提供从一个位图到另一个位图的平滑进度。
与使用
创建移动应用 Xamarin.Forms
的第 20 章中所述的最后一个程序一样,
Mandelbrot 动画中的 Mandelbrot
Set 的计算是具有八个参数的异步方法。 参数包括复杂中心点,以及围绕该中心点的复杂平面的宽度和高度。 接下来的三个参数是要创建的位图的像素宽度和高度,以及递归计算的最大迭代次数。 参数
progress
用于显示此计算的进度。
cancelToken
此程序未使用 参数:
static class Mandelbrot
public static Task<BitmapInfo> CalculateAsync(Complex center,
double width, double height,
int pixelWidth, int pixelHeight,
int iterations,
IProgress<double> progress,
CancellationToken cancelToken)
return Task.Run(() =>
int[] iterationCounts = new int[pixelWidth * pixelHeight];
int index = 0;
for (int row = 0; row < pixelHeight; row++)
progress.Report((double)row / pixelHeight);
cancelToken.ThrowIfCancellationRequested();
double y = center.Imaginary + height / 2 - row * height / pixelHeight;
for (int col = 0; col < pixelWidth; col++)
double x = center.Real - width / 2 + col * width / pixelWidth;
Complex c = new Complex(x, y);
if ((c - new Complex(-1, 0)).Magnitude < 1.0 / 4)
iterationCounts[index++] = -1;
// http://www.reenigne.org/blog/algorithm-for-mandelbrot-cardioid/
else if (c.Magnitude * c.Magnitude * (8 * c.Magnitude * c.Magnitude - 3) < 3.0 / 32 - c.Real)
iterationCounts[index++] = -1;
Complex z = 0;
int iteration = 0;
z = z * z + c;
iteration++;
while (iteration < iterations && z.Magnitude < 2);
if (iteration == iterations)
iterationCounts[index++] = -1;
iterationCounts[index++] = iteration;
return new BitmapInfo(pixelWidth, pixelHeight, iterationCounts);
}, cancelToken);
方法返回 类型的 BitmapInfo
对象,该对象提供创建位图的信息:
class BitmapInfo
public BitmapInfo(int pixelWidth, int pixelHeight, int[] iterationCounts)
PixelWidth = pixelWidth;
PixelHeight = pixelHeight;
IterationCounts = iterationCounts;
public int PixelWidth { private set; get; }
public int PixelHeight { private set; get; }
public int[] IterationCounts { private set; get; }
Mandelbrot 动画 XAML 文件包括两Label
个ProgressBar
视图:、 和 Button
SKCanvasView
以及 :
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:skia="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms"
x:Class="MandelAnima.MainPage"
Title="Mandelbrot Animation">
<StackLayout>
<Label x:Name="statusLabel"
HorizontalTextAlignment="Center" />
<ProgressBar x:Name="progressBar" />
<skia:SKCanvasView x:Name="canvasView"
VerticalOptions="FillAndExpand"
PaintSurface="OnCanvasViewPaintSurface" />
<StackLayout Orientation="Horizontal"
Padding="5">
<Label x:Name="storageLabel"
VerticalOptions="Center" />
<Button x:Name="deleteButton"
Text="Delete All"
HorizontalOptions="EndAndExpand"
Clicked="OnDeleteButtonClicked" />
</StackLayout>
</StackLayout>
</ContentPage>
代码隐藏文件首先定义三个关键常量和一个位图数组:
public partial class MainPage : ContentPage
const int COUNT = 10; // The number of bitmaps in the animation.
// This can go up to 50!
const int BITMAP_SIZE = 1000; // Program uses square bitmaps exclusively
// Uncomment just one of these, or define your own
static readonly Complex center = new Complex(-1.17651152924355, 0.298520986549558);
// static readonly Complex center = new Complex(-0.774693089457127, 0.124226621261617);
// static readonly Complex center = new Complex(-0.556624880053304, 0.634696788141351);
SKBitmap[] bitmaps = new SKBitmap[COUNT]; // array of bitmaps
在某个时候,你可能希望将 COUNT
值更改为 50 以查看动画的完整范围。 大于 50 的值没有用。 在 48 左右的缩放级别左右,双精度浮点数的分辨率不足以用于 Mandelbrot Set 计算。 使用 创建移动应用 Xamarin.Forms第 684 页中讨论了此问题。
值 center
非常重要。 这是动画缩放的焦点。 文件中的三个值在第 684 页上使用 创建移动应用 Xamarin.Forms 的第 20 章中的三个最终屏幕截图中使用的值,但你可以尝试该章中的程序,以得出自己的值之一。
Mandelbrot 动画示例将这些COUNT
位图存储在本地应用程序存储中。 50 个位图需要设备上超过 20 兆字节的存储空间,因此你可能想知道这些位图占用了多少存储空间,有时可能需要将其全部删除。 这就是 类底部的这两种方法的 MainPage
用途:
public partial class MainPage : ContentPage
void TallyBitmapSizes()
long fileSize = 0;
foreach (string filename in Directory.EnumerateFiles(FolderPath()))
fileSize += new FileInfo(filename).Length;
storageLabel.Text = $"Total storage: {fileSize:N0} bytes";
void OnDeleteButtonClicked(object sender, EventArgs args)
foreach (string filepath in Directory.EnumerateFiles(FolderPath()))
File.Delete(filepath);
TallyBitmapSizes();
当程序对这些相同的位图进行动画处理时,可以删除本地存储中的位图,因为程序会将它们保留在内存中。 但下次运行程序时,需要重新创建位图。
存储在本地应用程序存储 center
中的位图在其文件名中包含值,因此,如果更改 center
设置,现有位图将不会在存储中替换,并且将继续占用空间。
下面是用于构造文件名的方法 MainPage
,以及 MakePixel
用于基于颜色分量定义像素值的方法:
public partial class MainPage : ContentPage
// File path for storing each bitmap in local storage
string FolderPath() =>
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
string FilePath(int zoomLevel) =>
Path.Combine(FolderPath(),
String.Format("R{0}I{1}Z{2:D2}.png", center.Real, center.Imaginary, zoomLevel));
// Form bitmap pixel for Rgba8888 format
uint MakePixel(byte alpha, byte red, byte green, byte blue) =>
(uint)((alpha << 24) | (blue << 16) | (green << 8) | red);
的 zoomLevel
FilePath
参数范围为 0 到 COUNT
常量减 1。
构造 MainPage
函数调用 LoadAndStartAnimation
方法:
public partial class MainPage : ContentPage
public MainPage()
InitializeComponent();
LoadAndStartAnimation();
方法 LoadAndStartAnimation
负责访问应用程序本地存储,以加载之前运行程序时可能已创建的任何位图。 它循环访问 zoomLevel
从 0 到 COUNT
的值。 如果文件存在,它会将其加载到 数组中 bitmaps
。 否则,它需要通过调用 Mandelbrot.CalculateAsync
为特定 center
和 zoomLevel
值创建位图。 该方法获取每个像素的迭代计数,此方法将其转换为颜色:
public partial class MainPage : ContentPage
async void LoadAndStartAnimation()
// Show total bitmap storage
TallyBitmapSizes();
// Create progressReporter for async operation
Progress<double> progressReporter =
new Progress<double>((double progress) => progressBar.Progress = progress);
// Create (unused) CancellationTokenSource for async operation
CancellationTokenSource cancelTokenSource = new CancellationTokenSource();
// Loop through all the zoom levels
for (int zoomLevel = 0; zoomLevel < COUNT; zoomLevel++)
// If the file exists, load it
if (File.Exists(FilePath(zoomLevel)))
statusLabel.Text = $"Loading bitmap for zoom level {zoomLevel}";
using (Stream stream = File.OpenRead(FilePath(zoomLevel)))
bitmaps[zoomLevel] = SKBitmap.Decode(stream);
// Otherwise, create a new bitmap
statusLabel.Text = $"Creating bitmap for zoom level {zoomLevel}";
CancellationToken cancelToken = cancelTokenSource.Token;
// Do the (generally lengthy) Mandelbrot calculation
BitmapInfo bitmapInfo =
await Mandelbrot.CalculateAsync(center,
4 / Math.Pow(2, zoomLevel),
4 / Math.Pow(2, zoomLevel),
BITMAP_SIZE, BITMAP_SIZE,
(int)Math.Pow(2, 10), progressReporter, cancelToken);
// Create bitmap & get pointer to the pixel bits
SKBitmap bitmap = new SKBitmap(BITMAP_SIZE, BITMAP_SIZE, SKColorType.Rgba8888, SKAlphaType.Opaque);
IntPtr basePtr = bitmap.GetPixels();
// Set pixel bits to color based on iteration count
for (int row = 0; row < bitmap.Width; row++)
for (int col = 0; col < bitmap.Height; col++)
int iterationCount = bitmapInfo.IterationCounts[row * bitmap.Width + col];
uint pixel = 0xFF000000; // black
if (iterationCount != -1)
double proportion = (iterationCount / 32.0) % 1;
byte red = 0, green = 0, blue = 0;
if (proportion < 0.5)
red = (byte)(255 * (1 - 2 * proportion));
blue = (byte)(255 * 2 * proportion);
proportion = 2 * (proportion - 0.5);
green = (byte)(255 * proportion);
blue = (byte)(255 * (1 - proportion));
pixel = MakePixel(0xFF, red, green, blue);
// Calculate pointer to pixel
IntPtr pixelPtr = basePtr + 4 * (row * bitmap.Width + col);
unsafe // requires compiling with unsafe flag
*(uint*)pixelPtr.ToPointer() = pixel;
// Save as PNG file
SKData data = SKImage.FromBitmap(bitmap).Encode();
File.WriteAllBytes(FilePath(zoomLevel), data.ToArray());
catch
// Probably out of space, but just ignore
// Store in array
bitmaps[zoomLevel] = bitmap;
// Show new bitmap sizes
TallyBitmapSizes();
// Display the bitmap
bitmapIndex = zoomLevel;
canvasView.InvalidateSurface();
// Now start the animation
stopwatch.Start();
Device.StartTimer(TimeSpan.FromMilliseconds(16), OnTimerTick);
请注意,程序将这些位图存储在本地应用程序存储中,而不是存储在设备的照片库中。 .NET Standard 2.0 库允许为此任务使用熟悉 File.OpenRead
的 和 File.WriteAllBytes
方法。
创建所有位图或将其加载到内存中后, 方法将启动 对象 Stopwatch
并调用 Device.StartTimer
。 每 OnTimerTick
16 毫秒调用一次 方法。
OnTimerTick
计算一个 time
介于 0 到 6000 次 COUNT
之间的值(以毫秒为单位),计算每个位图显示 6 秒的值。 值 progress
使用 Math.Sin
值创建一个正弦动画,该动画在循环开始时会变慢,在反转方向时在末尾变慢。
该值 progress
的范围是从 0 到 COUNT
。 这意味着 的整数部分 progress
是数组中的 bitmaps
索引,而 的小 progress
数部分指示该特定位图的缩放级别。 这些值存储在 bitmapIndex
和 bitmapProgress
字段中,并由 Label
XAML 文件中的 和 Slider
显示。 将 SKCanvasView
失效以更新位图显示:
public partial class MainPage : ContentPage
Stopwatch stopwatch = new Stopwatch(); // for the animation
int bitmapIndex;
double bitmapProgress = 0;
bool OnTimerTick()
int cycle = 6000 * COUNT; // total cycle length in milliseconds
// Time in milliseconds from 0 to cycle
int time = (int)(stopwatch.ElapsedMilliseconds % cycle);
// Make it sinusoidal, including bitmap index and gradation between bitmaps
double progress = COUNT * 0.5 * (1 + Math.Sin(2 * Math.PI * time / cycle - Math.PI / 2));
// These are the field values that the PaintSurface handler uses
bitmapIndex = (int)progress;
bitmapProgress = progress - bitmapIndex;
// It doesn't often happen that we get up to COUNT, but an exception would be raised
if (bitmapIndex < COUNT)
// Show progress in UI
statusLabel.Text = $"Displaying bitmap for zoom level {bitmapIndex}";
progressBar.Progress = bitmapProgress;
// Update the canvas
canvasView.InvalidateSurface();
return true;
最后, PaintSurface
的 SKCanvasView
处理程序计算一个目标矩形,以在保持纵横比的同时尽可能显示位图。 源矩形基于 bitmapProgress
值。 fraction
此处计算的值范围为 0(当 为 0 时bitmapProgress
显示整个位图)到 0.25(当 为 1 时bitmapProgress
显示位图的宽度和高度的一半),有效放大:
public partial class MainPage : ContentPage
void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
SKImageInfo info = args.Info;
SKSurface surface = args.Surface;
SKCanvas canvas = surface.Canvas;
canvas.Clear();
if (bitmaps[bitmapIndex] != null)
// Determine destination rect as square in canvas
int dimension = Math.Min(info.Width, info.Height);
float x = (info.Width - dimension) / 2;
float y = (info.Height - dimension) / 2;
SKRect destRect = new SKRect(x, y, x + dimension, y + dimension);
// Calculate source rectangle based on fraction:
// bitmapProgress == 0: full bitmap
// bitmapProgress == 1: half of length and width of bitmap
float fraction = 0.5f * (1 - (float)Math.Pow(2, -bitmapProgress));
SKBitmap bitmap = bitmaps[bitmapIndex];
int width = bitmap.Width;
int height = bitmap.Height;
SKRect sourceRect = new SKRect(fraction * width, fraction * height,
(1 - fraction) * width, (1 - fraction) * height);
// Display the bitmap
canvas.DrawBitmap(bitmap, sourceRect, destRect);
下面是正在运行的程序:
GIF 动画
图形交换格式 (GIF) 规范包含一项功能,该功能允许单个 GIF 文件包含场景的多个顺序帧,这些帧通常以循环形式连续显示。 这些文件称为 动画 GIF。 Web 浏览器可以播放动画 GIF,并且 SkiaSharp 允许应用程序从动态 GIF 文件中提取帧并按顺序显示它们。
SkiaSharpFormsDemos 示例包含一个名为 Newtons_cradle_animation_book_2.gif 的动画 GIF 资源,该资源由 DemonDeLuxe 创建,并从维基百科的牛顿摇篮页面下载。 动画 GIF 页包含一个 XAML 文件,该文件提供该信息并实例化 :SKCanvasView
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:skia="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms"
x:Class="SkiaSharpFormsDemos.Bitmaps.AnimatedGifPage"
Title="Animated GIF">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<skia:SKCanvasView x:Name="canvasView"
Grid.Row="0"
PaintSurface="OnCanvasViewPaintSurface" />
<Label Text="GIF file by DemonDeLuxe from Wikipedia Newton's Cradle page"
Grid.Row="1"
Margin="0, 5"
HorizontalTextAlignment="Center" />
</Grid>
</ContentPage>
代码隐藏文件不会通用化,无法播放任何动态 GIF 文件。 它忽略一些可用的信息,特别是重复计数,并只是在循环中播放动画 GIF。
使用 SkisSharp 提取动画 GIF 文件的帧似乎并未记录到任何位置,因此以下代码的说明比平时更详细:
动态 GIF 文件的解码在页面的构造函数中发生,并要求Stream
引用位图的对象用于创建对象,然后SKCodec
创建 SKManagedStream
对象。 属性 FrameCount
指示构成动画的帧数。
这些帧最终保存为单独的位图,因此构造函数使用 FrameCount
为每个帧的持续时间分配类型 SKBitmap
为 的数组以及两 int
个数组, (来简化动画逻辑) 累积的持续时间。
FrameInfo
类的 SKCodec
属性是一个值数组SKCodecFrameInfo
,每个帧对应一个值,但此程序从该结构获取的唯一内容是Duration
帧的 ,以毫秒为单位。
SKCodec
定义一个名为 Info
类型的 SKImageInfo
属性,但该值 SKImageInfo
指示至少 (此图像) 颜色类型为 SKColorType.Index8
,这意味着每个像素都是颜色类型的索引。 为了避免使用颜色表,程序使用该结构中的 Width
和 Height
信息来构造它自己的全色 ImageInfo
值。 每个都是 SKBitmap
从中创建的。
GetPixels
的 SKBitmap
方法返回引用IntPtr
该位图的像素位的 。 这些像素位尚未设置。 它IntPtr
传递给 的方法之GetPixels
SKCodec
一。 该方法将帧从 GIF 文件复制到 引用的内存空间中 IntPtr
。 构造 SKCodecOptions
函数指示帧编号:
public partial class AnimatedGifPage : ContentPage
SKBitmap[] bitmaps;
int[] durations;
int[] accumulatedDurations;
int totalDuration;
public AnimatedGifPage ()
InitializeComponent ();
string resourceID = "SkiaSharpFormsDemos.Media.Newtons_cradle_animation_book_2.gif";
Assembly assembly = GetType().GetTypeInfo().Assembly;
using (Stream stream = assembly.GetManifestResourceStream(resourceID))
using (SKManagedStream skStream = new SKManagedStream(stream))
using (SKCodec codec = SKCodec.Create(skStream))
// Get frame count and allocate bitmaps
int frameCount = codec.FrameCount;
bitmaps = new SKBitmap[frameCount];
durations = new int[frameCount];
accumulatedDurations = new int[frameCount];
// Note: There's also a RepetitionCount property of SKCodec not used here
// Loop through the frames
for (int frame = 0; frame < frameCount; frame++)
// From the FrameInfo collection, get the duration of each frame
durations[frame] = codec.FrameInfo[frame].Duration;
// Create a full-color bitmap for each frame
SKImageInfo imageInfo = code.new SKImageInfo(codec.Info.Width, codec.Info.Height);
bitmaps[frame] = new SKBitmap(imageInfo);
// Get the address of the pixels in that bitmap
IntPtr pointer = bitmaps[frame].GetPixels();
// Create an SKCodecOptions value to specify the frame
SKCodecOptions codecOptions = new SKCodecOptions(frame, false);
// Copy pixels from the frame into the bitmap
codec.GetPixels(imageInfo, pointer, codecOptions);
// Sum up the total duration
for (int frame = 0; frame < durations.Length; frame++)
totalDuration += durations[frame];
// Calculate the accumulated durations
for (int frame = 0; frame < durations.Length; frame++)
accumulatedDurations[frame] = durations[frame] +
(frame == 0 ? 0 : accumulatedDurations[frame - 1]);
IntPtr
尽管有 值,但不需要任何unsafe
代码,IntPtr
因为 永远不会转换为 C# 指针值。
提取每个帧后,构造函数将汇总所有帧的持续时间,然后使用累积的持续时间初始化另一个数组。
代码隐藏文件的其余部分专用于动画。 方法 Device.StartTimer
用于启动计时器运行,回调 OnTimerTick
使用 Stopwatch
对象确定运行时间(以毫秒为单位)。 循环访问累积持续时间数组足以找到当前帧:
public partial class AnimatedGifPage : ContentPage
SKBitmap[] bitmaps;
int[] durations;
int[] accumulatedDurations;
int totalDuration;
Stopwatch stopwatch = new Stopwatch();
bool isAnimating;
int currentFrame;
protected override void OnAppearing()
base.OnAppearing();
isAnimating = true;
stopwatch.Start();
Device.StartTimer(TimeSpan.FromMilliseconds(16), OnTimerTick);
protected override void OnDisappearing()
base.OnDisappearing();
stopwatch.Stop();
isAnimating = false;
bool OnTimerTick()
int msec = (int)(stopwatch.ElapsedMilliseconds % totalDuration);
int frame = 0;
// Find the frame based on the elapsed time
for (frame = 0; frame < accumulatedDurations.Length; frame++)
if (msec < accumulatedDurations[frame])
break;
// Save in a field and invalidate the SKCanvasView.
if (currentFrame != frame)
currentFrame = frame;
canvasView.InvalidateSurface();
return isAnimating;
void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
SKImageInfo info = args.Info;
SKSurface surface = args.Surface;
SKCanvas canvas = surface.Canvas;
canvas.Clear(SKColors.Black);
// Get the bitmap and center it
SKBitmap bitmap = bitmaps[currentFrame];
canvas.DrawBitmap(bitmap,info.Rect, BitmapStretch.Uniform);
每次 currentframe
变量更改时 SKCanvasView
, 都会失效,并显示新帧:
当然,你需要自己运行程序来查看动画。
SkiaSharp API
SkiaSharpFormsDemos (示例)
Mandelbrot 动画 (示例)