相关文章推荐

对 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 文件包括两LabelProgressBar视图:、 和 ButtonSKCanvasView以及 :

<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);

zoomLevelFilePath 参数范围为 0 到 COUNT 常量减 1。

构造 MainPage 函数调用 LoadAndStartAnimation 方法:

public partial class MainPage : ContentPage
    public MainPage()
        InitializeComponent();
        LoadAndStartAnimation();

方法 LoadAndStartAnimation 负责访问应用程序本地存储,以加载之前运行程序时可能已创建的任何位图。 它循环访问 zoomLevel 从 0 到 COUNT的值。 如果文件存在,它会将其加载到 数组中 bitmaps 。 否则,它需要通过调用 Mandelbrot.CalculateAsync为特定 centerzoomLevel 值创建位图。 该方法获取每个像素的迭代计数,此方法将其转换为颜色:

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 数部分指示该特定位图的缩放级别。 这些值存储在 bitmapIndexbitmapProgress 字段中,并由 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;

最后, PaintSurfaceSKCanvasView 处理程序计算一个目标矩形,以在保持纵横比的同时尽可能显示位图。 源矩形基于 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,这意味着每个像素都是颜色类型的索引。 为了避免使用颜色表,程序使用该结构中的 WidthHeight 信息来构造它自己的全色 ImageInfo 值。 每个都是 SKBitmap 从中创建的。

GetPixelsSKBitmap 方法返回引用IntPtr该位图的像素位的 。 这些像素位尚未设置。 它IntPtr传递给 的方法之GetPixelsSKCodec一。 该方法将帧从 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 动画 (示例)
  •  
    推荐文章