对 SkiaSharp 位图进行动画处理

对 SkiaSharp 图形进行动画处理的应用程序通常以固定频率(通常为每隔 16 毫秒)调用 SKCanvasView 上的 InvalidateSurface。 使表面失效会触发对 PaintSurface 处理程序的调用以重绘显示内容。 由于视觉对象每秒重绘 60 次,因此它们看起来是流畅的动画。

但是,如果图形因太复杂而无法在 16 毫秒内渲染,则动画可能会变得不稳定。 程序员可能会选择将刷新率降低到每秒 30 次或 15 次,但有时甚至这还不够。 有时图形非常复杂,以至于无法实时渲染。

一种解决方案是通过在一系列位图上渲染动画的各个帧来预先准备动画。 若要显示动画,只需每秒按顺序显示这些位图 60 次即可。

当然,这可能需要大量位图,但这就是大预算 3D 动画电影的制作方式。 3D 图形过于复杂,因而无法实时渲染。 渲染每一帧需要大量的处理时间。 观看电影时,你看到的在本质上是一系列位图。

可以在 SkiaSharp 中执行类似的操作。 本文演示两种类型的位图动画。 第一个示例是 Mandelbrot Set 的动画:

动画处理示例

第二个示例演示如何使用 SkiaSharp 渲染动画 GIF 文件。

位图动画

Mandelbrot Set 在视觉上令人着迷,但计算时间却很长。 (有关 Mandelbrot Set 和此处使用的数学的讨论,请参阅使用 Xamarin.Forms 创建移动应用的第 20 章,从第 666 页开始。以下描述假设读者拥有背景知识。)

该示例使用位图动画来模拟 Mandelbrot Set 中固定点的连续缩放。 放大之后是缩小,然后循环不断重复或直到程序结束。

程序通过创建最多 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;
                    }
                    else
                    {
                        Complex z = 0;
                        int iteration = 0;

                        do
                        {
                            z = z * z + c;
                            iteration++;
                        }
                        while (iteration < iterations && z.Magnitude < 2);

                        if (iteration == iterations)
                        {
                            iterationCounts[index++] = -1;
                        }
                        else
                        {
                            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 值非常重要。 这是动画缩放的焦点。 文件中的三个值是“使用 Xamarin.Forms 创建移动应用”第 684 页第 20 章中的三个最终屏幕截图中使用的值,但你可以试验该章中的程序,得出一个你自己的值

“Mandelbrot 动画”示例将这些 COUNT 位图存储在本地应用程序存储中。 50 个位图需要占用设备上超过 20 MB 的存储空间,因此你可能想知道这些位图确切占用了多少存储空间,并在某个时候将其全部删除。 这就是 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 方法负责访问应用程序本地存储,以加载先前运行程序时可能创建的任何位图。 它循环访问从 0 到 COUNTzoomLevel 值。 如果文件存在,则会将其加载到 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
            else
            {
                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);
                            }
                            else
                            {
                                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();

                try
                {
                    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.OpenReadFile.WriteAllBytes 方法来完成此任务。

创建所有位图或将其加载到内存后,该方法将启动一个 Stopwatch 对象并调用 Device.StartTimer。 每隔 16 毫秒调用一次 OnTimerTick 方法。

OnTimerTick 计算一个 time 值(以毫秒为单位),其范围为 0 到 6000 乘以 COUNT,为每个位图的显示分配 6 秒。 progress 值使用 Math.Sin 值创建正弦动画,该动画在循环开始时会变慢,在结束时会变慢,因为方向相反。

progress 值的范围为从 0 到 COUNT。 这意味着 progress 的整数部分是 bitmaps 数组的索引,而 progress 的小数部分表示该特定位图的缩放级别。 这些值存储在 bitmapIndexbitmapProgress 字段中,并由 XAML 文件中的 LabelSlider 显示。 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;
    }
    ···
}

最后,SKCanvasViewPaintSurface 处理程序计算一个目标矩形,以便在保持纵横比的同时显示尽可能大的位图。 源矩形基于 bitmapProgress 值。 此处计算的 fraction 值范围为 0(当 bitmapProgress 为 0 时显示整个位图)到 0.25(当 bitmapProgress 为 1 时显示位图宽度和高度的一半),从而有效地放大:

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

下面是正在运行的程序:

Mandelbrot 动画

GIF 动画

图形交换格式 (GIF) 规范包含一项功能,它允许单个 GIF 文件包含场景的多个可以连续显示(通常是循环显示)的连续帧。 这些文件称为动画 GIF。 Web 浏览器可以播放动画 GIF,SkiaSharp 允许应用程序从动画 GIF 文件中提取帧并按顺序显示它们。

该示例包含一个名为 Newtons_cradle_animation_book_2.gif 的动画 GIF 资源,该资源由 DemonDeLuxe 创建,并可以从维基百科的 Newton's Cradle 页下载。 “动画 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>
        <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 数组。

SKCodec 类的 FrameInfo 属性是一个包含 SKCodecFrameInfo 值的数组,每个值对应一个帧,但此程序从该结构中获取的唯一内容是帧的 Duration(以毫秒为单位)。

SKCodec 定义一个名为 Info、类型为 SKImageInfo 的属性,但该 SKImageInfo 值指示(至少对于此图像)颜色类型为 SKColorType.Index8,这意味着每个像素都是颜色类型的索引。 为了避免颜色表产生的困扰,程序使用该结构中的 WidthHeight 信息来构造它自己的全色 ImageInfo 值。 每个 SKBitmap 都是基于这些信息创建的。

SKBitmapGetPixels 方法返回引用该位图像素位的 IntPtr。 尚未设置这些像素位。 该 IntPtr 将传递给 SKCodecGetPixels 方法之一。 该方法将帧从 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 就会失效并显示新帧:

动画 GIF

当然,你需要自行运行程序才能观看动画。