将 SkiaSharp 位图保存到文件

在 SkiaSharp 应用程序创建或修改位图后,应用程序可能想要将位图保存到用户的照片图库:

保存位图

此任务包含两个步骤:

  • 将 SkiaSharp 位图转换为特定文件格式的数据,例如 JPEG 或 PNG。
  • 使用特定于平台的代码将结果保存到照片图库。

文件格式和编解码器

如今大多数常用的位图文件格式都使用压缩来减少存储空间。 两种广泛的压缩技术称为“有损”和“无损”。 这两个词是指压缩算法是否会导致数据损失。

最受欢迎的有损格式是由联合图像专家组开发的,称为 JPEG。 JPEG 压缩算法使用称为“离散余弦变换”的数学工具分析图像,并尝试删除对保持图像视觉保真度不重要的数据。 可使用通常称为“质量”的设置来控制压缩程度。 质量设置越高,文件越大。

相比之下,无损压缩算法会分析图像中的重复和像素模式,可以一种减少数据但不会导致任何信息丢失的方式进行编码。 原始位图数据可以完全从压缩文件中恢复。 目前使用的主要无损压缩文件格式是可移植网络图形格式 (PNG)。

通常,JPEG 用于照片,而 PNG 用于手动生成或算法生成的图像。 任何减少某些文件大小的无损压缩算法必然会增加其他文件的大小。 幸运的是,这种大小的增加通常只发生在包含大量随机(或看似随机)信息的数据上。

压缩算法非常复杂,有必要用这两个术语来描述压缩和解压缩过程:

  • 解码 - 读取位图文件格式并将其解压缩
  • 编码 - 压缩位图并写入位图文件格式

SKBitmap 类包含几个名为 Decode 的方法,这些方法根据压缩源创建 SKBitmap。 只需提供文件名、流或字节数组即可。 解码器可以确定文件格式并将其传递给适当的内部解码函数。

此外,SKCodec 类具有两个名为 Create 的方法,这些方法可根据压缩源创建 SKCodec 对象,并使应用程序能够更多地参与解码过程。 (有关 SKCodec 类,请参阅与解码动态 GIF 文件相关的对 SkiaSharp 位图进行动画处理一文。)

编码位图时,需要更多信息:编码器必须知道应用程序要使用的特定文件格式(JPEG 或 PNG 等)。 如果需要有损格式,编码还必须知道所需的质量级别。

SKBitmap 类使用以下语法定义一个 Encode 方法:

public Boolean Encode (SKWStream dst, SKEncodedImageFormat format, Int32 quality)

稍后会更详细地介绍此方法。 编码后的位图将写入可写流。 (SKWStream 中的“W”代表“可写”。)第二个和第三个参数指定文件格式和(对于有损格式)所需质量(范围在 0 到 100 之间)。

此外,SKImageSKPixmap 类还定义了 Encode 方法,它们在一定程度上更加通用,你可能更喜欢。 可以使用静态 SKImage.FromBitmap 方法根据 SKBitmap 对象轻松创建 SKImage 对象。 可以使用 PeekPixels 方法根据 SKBitmap 对象获取 SKPixmap 对象。

SKImage 定义的其中一种 Encode 方法没有参数,并自动保存到 PNG 格式。 这种无参数方法非常易于使用。

用于保存位图文件的平台特定代码

SKBitmap 对象编码为特定文件格式时,通常将保留某种类型的流对象或者数据数组。 某些 Encode 方法(包括 SKImage 定义的无参数方法)返回一个 SKData 对象,可使用 ToArray 方法将此对象转换为字节数组。 然后,必须将此数据保存到文件中。

很简单就能保存到应用程序本地存储中的文件,因为在此任务中,你可使用标准 System.IO 类和方法。 有关此技术,请参阅与为 Mandelbrot 集的一系列位图创建动画相关的对 SkiaSharp 位图进行动画处理一文。

如果希望文件由其他应用程序共享,必须将其保存到用户的照片图库。 此任务需要特定于平台的代码并使用 Xamarin.FormsDependencyService

示例应用程序中的 SkiaSharpFormsDemo 项目定义了与 DependencyService 类一起使用的 IPhotoLibrary 接口。 这定义了 SavePhotoAsync 方法的语法:

public interface IPhotoLibrary
{
    Task<Stream> PickPhotoAsync();

    Task<bool> SavePhotoAsync(byte[] data, string folder, string filename);
}

此接口还定义了 PickPhotoAsync 方法,该方法用于打开设备照片图库的平台特定文件选取器。

对于 SavePhotoAsync,第一个参数是一个字节数组,其中包含已编码为特定文件格式(例如 JPEG 或 PNG)的位图。 应用程序可能希望将它创建的所有位图隔离到特定文件夹中,该文件夹在下一个参数中指定,后跟文件名。 该方法返回一个指示是否成功的布尔值。

以下部分将讨论 SavePhotoAsync 在每个平台上的实现方式。

iOS 实现

SavePhotoAsync 的 iOS 实现使用 UIImageSaveToPhotosAlbum 方法:

public class PhotoLibrary : IPhotoLibrary
{
    ···
    public Task<bool> SavePhotoAsync(byte[] data, string folder, string filename)
    {
        NSData nsData = NSData.FromArray(data);
        UIImage image = new UIImage(nsData);
        TaskCompletionSource<bool> taskCompletionSource = new TaskCompletionSource<bool>();

        image.SaveToPhotosAlbum((UIImage img, NSError error) =>
        {
            taskCompletionSource.SetResult(error == null);
        });

        return taskCompletionSource.Task;
    }
}

遗憾的是,无法指定图像的文件名或文件夹。

iOS 项目中的 Info.plist 文件需要一个键,指示它将图像添加到照片图库:

<key>NSPhotoLibraryAddUsageDescription</key>
<string>SkiaSharp Forms Demos adds images to your photo library</string>

请小心! 仅访问照片图库的权限键非常相似,但不相同:

<key>NSPhotoLibraryUsageDescription</key>
<string>SkiaSharp Forms Demos accesses your photo library</string>

Android 实现

SavePhotoAsync 的 Android 实现首先检查 folder 参数是否为 null 或空字符串。 如果是,则位图保存在照片图库的根目录中。 否则,会获取文件夹,如果不存在,则会创建它:

public class PhotoLibrary : IPhotoLibrary
{
    ···
    public async Task<bool> SavePhotoAsync(byte[] data, string folder, string filename)
    {
        try
        {
            File picturesDirectory = Environment.GetExternalStoragePublicDirectory(Environment.DirectoryPictures);
            File folderDirectory = picturesDirectory;

            if (!string.IsNullOrEmpty(folder))
            {
                folderDirectory = new File(picturesDirectory, folder);
                folderDirectory.Mkdirs();
            }

            using (File bitmapFile = new File(folderDirectory, filename))
            {
                bitmapFile.CreateNewFile();

                using (FileOutputStream outputStream = new FileOutputStream(bitmapFile))
                {
                    await outputStream.WriteAsync(data);
                }

                // Make sure it shows up in the Photos gallery promptly.
                MediaScannerConnection.ScanFile(MainActivity.Instance,
                                                new string[] { bitmapFile.Path },
                                                new string[] { "image/png", "image/jpeg" }, null);
            }
        }
        catch
        {
            return false;
        }

        return true;
    }
}

并非严格要求调用 MediaScannerConnection.ScanFile,但如果通过立即检查照片图库来测试程序,那么更新图库库视图会有很大帮助。

AndroidManifest.xml 文件需要以下权限标记:

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

UWP 实现

SavePhotoAsync 的 UWP 实现在结构上与 Android 实现非常相似:

public class PhotoLibrary : IPhotoLibrary
{
    ···
    public async Task<bool> SavePhotoAsync(byte[] data, string folder, string filename)
    {
        StorageFolder picturesDirectory = KnownFolders.PicturesLibrary;
        StorageFolder folderDirectory = picturesDirectory;

        // Get the folder or create it if necessary
        if (!string.IsNullOrEmpty(folder))
        {
            try
            {
                folderDirectory = await picturesDirectory.GetFolderAsync(folder);
            }
            catch
            { }

            if (folderDirectory == null)
            {
                try
                {
                    folderDirectory = await picturesDirectory.CreateFolderAsync(folder);
                }
                catch
                {
                    return false;
                }
            }
        }

        try
        {
            // Create the file.
            StorageFile storageFile = await folderDirectory.CreateFileAsync(filename,
                                                CreationCollisionOption.GenerateUniqueName);

            // Convert byte[] to Windows buffer and write it out.
            IBuffer buffer = WindowsRuntimeBuffer.Create(data, 0, data.Length, data.Length);
            await FileIO.WriteBufferAsync(storageFile, buffer);
        }
        catch
        {
            return false;
        }

        return true;
    }
}

Package.appxmanifest 文件的“功能”部分需要图片库。

了解图像格式

下面还是 SKImageEncode 方法:

public Boolean Encode (SKWStream dst, SKEncodedImageFormat format, Int32 quality)

SKEncodedImageFormat 是一个枚举,其成员引用了 11 种位图文件格式,其中一些格式相当模糊:

  • Astc - 自适应可伸缩纹理压缩
  • Bmp - Windows 位图
  • Dng - Adobe Digital Negative
  • Gif - 图形交换格式
  • Ico - Windows 图标图像
  • Jpeg - 联合图像专家组
  • Ktx - OpenGL 的 Khronos 纹理格式
  • Pkm - GrafX2 的自定义格式
  • Png - 可移植网络图形格式
  • Wbmp - 无线应用协议位图格式(每像素 1 位)
  • Webp - Google WebP 格式

你很快就会看到,SkiaSharp 实际上只支持其中三种文件格式(JpegPngWebp)。

若要将名为 bitmapSKBitmap 对象保存到用户的照片图库,还需要一个名为 imageFormatSKEncodedImageFormat 枚举的成员和(对于有损格式)一个整数 quality 变量。 可使用以下代码将该位图保存到 folder 文件夹中名为 filename 的文件:

using (MemoryStream memStream = new MemoryStream())
using (SKManagedWStream wstream = new SKManagedWStream(memStream))
{
    bitmap.Encode(wstream, imageFormat, quality);
    byte[] data = memStream.ToArray();

    // Check the data array for content!

    bool success = await DependencyService.Get<IPhotoLibrary>().SavePhotoAsync(data, folder, filename);

    // Check return value for success!
}

SKManagedWStream 类派生自 SKWStream(它表示“可写流”)。 Encode 方法将编码后的位图文件写入该流。 该代码中的注释引用了你可能需要执行的一些错误检查。

示例应用程序中的“保存文件格式”页面使用类似的代码,让你能够尝试以各种格式保存位图。

XAML 文件包含一个显示位图的 SKCanvasView,而页面的其余部分包含应用程序调用 SKBitmapEncode 方法所需的一切信息。 它包含一个 Picker(用于 SKEncodedImageFormat 枚举的成员)、一个 Slider(用于有损位图格式的质量参数)、两个 Entry 视图(用于文件名和文件夹名称)和一个 Button(用于保存文件)。

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:skia="clr-namespace:SkiaSharp;assembly=SkiaSharp"
             xmlns:skiaforms="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms"
             x:Class="SkiaSharpFormsDemos.Bitmaps.SaveFileFormatsPage"
             Title="Save Bitmap Formats">

    <StackLayout Margin="10">
        <skiaforms:SKCanvasView PaintSurface="OnCanvasViewPaintSurface"
                                VerticalOptions="FillAndExpand" />

        <Picker x:Name="formatPicker"
                Title="image format"
                SelectedIndexChanged="OnFormatPickerChanged">
            <Picker.ItemsSource>
                <x:Array Type="{x:Type skia:SKEncodedImageFormat}">
                    <x:Static Member="skia:SKEncodedImageFormat.Astc" />
                    <x:Static Member="skia:SKEncodedImageFormat.Bmp" />
                    <x:Static Member="skia:SKEncodedImageFormat.Dng" />
                    <x:Static Member="skia:SKEncodedImageFormat.Gif" />
                    <x:Static Member="skia:SKEncodedImageFormat.Ico" />
                    <x:Static Member="skia:SKEncodedImageFormat.Jpeg" />
                    <x:Static Member="skia:SKEncodedImageFormat.Ktx" />
                    <x:Static Member="skia:SKEncodedImageFormat.Pkm" />
                    <x:Static Member="skia:SKEncodedImageFormat.Png" />
                    <x:Static Member="skia:SKEncodedImageFormat.Wbmp" />
                    <x:Static Member="skia:SKEncodedImageFormat.Webp" />
                </x:Array>
            </Picker.ItemsSource>
        </Picker>

        <Slider x:Name="qualitySlider"
                Maximum="100"
                Value="50" />

        <Label Text="{Binding Source={x:Reference qualitySlider},
                              Path=Value,
                              StringFormat='Quality = {0:F0}'}"
               HorizontalTextAlignment="Center" />

        <StackLayout Orientation="Horizontal">
            <Label Text="Folder Name: "
                   VerticalOptions="Center" />

            <Entry x:Name="folderNameEntry"
                   Text="SaveFileFormats"
                   HorizontalOptions="FillAndExpand" />
        </StackLayout>

        <StackLayout Orientation="Horizontal">
            <Label Text="File Name: "
                   VerticalOptions="Center" />

            <Entry x:Name="fileNameEntry"
                   Text="Sample.xxx"
                   HorizontalOptions="FillAndExpand" />
        </StackLayout>

        <Button Text="Save"
                Clicked="OnButtonClicked">
            <Button.Triggers>
                <DataTrigger TargetType="Button"
                             Binding="{Binding Source={x:Reference formatPicker},
                                               Path=SelectedIndex}"
                             Value="-1">
                    <Setter Property="IsEnabled" Value="False" />
                </DataTrigger>

                <DataTrigger TargetType="Button"
                             Binding="{Binding Source={x:Reference fileNameEntry},
                                               Path=Text.Length}"
                             Value="0">
                    <Setter Property="IsEnabled" Value="False" />
                </DataTrigger>
            </Button.Triggers>
        </Button>

        <Label x:Name="statusLabel"
               Text="OK"
               Margin="10, 0" />
    </StackLayout>
</ContentPage>

代码隐藏文件会加载位图资源并使用 SKCanvasView 来显示它。 该位图永远不会更改。 PickerSelectedIndexChanged 处理程序使用与枚举成员相同的扩展名修改文件名:

public partial class SaveFileFormatsPage : ContentPage
{
    SKBitmap bitmap = BitmapExtensions.LoadBitmapResource(typeof(SaveFileFormatsPage),
        "SkiaSharpFormsDemos.Media.MonkeyFace.png");

    public SaveFileFormatsPage ()
    {
        InitializeComponent ();
    }

    void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
    {
        args.Surface.Canvas.DrawBitmap(bitmap, args.Info.Rect, BitmapStretch.Uniform);
    }

    void OnFormatPickerChanged(object sender, EventArgs args)
    {
        if (formatPicker.SelectedIndex != -1)
        {
            SKEncodedImageFormat imageFormat = (SKEncodedImageFormat)formatPicker.SelectedItem;
            fileNameEntry.Text = Path.ChangeExtension(fileNameEntry.Text, imageFormat.ToString());
            statusLabel.Text = "OK";
        }
    }

    async void OnButtonClicked(object sender, EventArgs args)
    {
        SKEncodedImageFormat imageFormat = (SKEncodedImageFormat)formatPicker.SelectedItem;
        int quality = (int)qualitySlider.Value;

        using (MemoryStream memStream = new MemoryStream())
        using (SKManagedWStream wstream = new SKManagedWStream(memStream))
        {
            bitmap.Encode(wstream, imageFormat, quality);
            byte[] data = memStream.ToArray();

            if (data == null)
            {
                statusLabel.Text = "Encode returned null";
            }
            else if (data.Length == 0)
            {
                statusLabel.Text = "Encode returned empty array";
            }
            else
            {
                bool success = await DependencyService.Get<IPhotoLibrary>().
                    SavePhotoAsync(data, folderNameEntry.Text, fileNameEntry.Text);

                if (!success)
                {
                    statusLabel.Text = "SavePhotoAsync return false";
                }
                else
                {
                    statusLabel.Text = "Success!";
                }
            }
        }
    }
}

用于 ButtonClicked 处理程序执行所有实际工作。 它从 PickerSlider 获取 Encode 的两个参数,然后使用上面显示的代码为 Encode 方法创建一个 SKManagedWStream。 两个 Entry 视图为 SavePhotoAsync 方法提供文件夹名称和文件名。

此方法的大部分致力于处理问题或错误。 如果 Encode 创建空数组,则表示特定文件格式不受支持。 如果 SavePhotoAsync 返回 false,则文件未成功保存。

下面是正在运行的程序:

保存文件格式

该屏幕截图显示了这些平台上仅支持的三种格式:

  • JPEG
  • PNG
  • WebP

对于其他所有格式,Encode 方法不向流写入任何内容,生成的字节数组为空。

“保存文件格式”页面保存的位图为 600 像素正方形。 每个像素 4 个字节,内存中总共有 1,440,000 个字节。 下表显示了文件格式和质量的各种组合的文件大小:

Format 质量 大小
PNG 空值 492K
JPEG 0 2.95K
50 22.1K
100 206K
WebP 0 2.71K
50 11.9K
100 101K

可以尝试各种质量设置并检查结果。

保存手指绘制艺术

位图的一种常见用途是在绘图程序中,在这里它充当称为“阴影位图”的功能。 所有绘图都保留在位图上,然后由程序显示。 位图在保存绘图时也很方便。

SkiaSharp 中的手指绘制一文演示了如何使用触摸跟踪来实现原始的手指绘制程序。 该程序仅支持一种颜色和一种笔划宽度,但它将整个绘图都保留在 SKPath 对象集合中。

示例中的“手指绘制并保存”页面也会将整个绘图保留在 SKPath 对象集合中,但它也会在位图上呈现绘图,该位图可保存到照片图库中。

此程序的大部分类似于原始的手指绘图程序。 一个增强功能是,XAML 文件现在实例化了标记为“清除”和“保存”的按钮:

<?xml version="1.0" encoding="utf-8" ?>
<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"
             xmlns:tt="clr-namespace:TouchTracking"
             x:Class="SkiaSharpFormsDemos.Bitmaps.FingerPaintSavePage"
             Title="Finger Paint Save">

    <StackLayout>
        <Grid BackgroundColor="White"
              VerticalOptions="FillAndExpand">
            <skia:SKCanvasView x:Name="canvasView"
                               PaintSurface="OnCanvasViewPaintSurface" />
            <Grid.Effects>
                <tt:TouchEffect Capture="True"
                                TouchAction="OnTouchEffectAction" />
            </Grid.Effects>
        </Grid>

        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="*" />
            </Grid.ColumnDefinitions>
        </Grid>

        <Button Text="Clear"
                Grid.Row="0"
                Margin="50, 5"
                Clicked="OnClearButtonClicked" />

        <Button Text="Save"
                Grid.Row="1"
                Margin="50, 5"
                Clicked="OnSaveButtonClicked" />

    </StackLayout>
</ContentPage>

代码隐藏文件维护一个 SKBitmap 类型的名为 saveBitmap 的字段。 每当显示图面的大小发生变化时,此位图都会在 PaintSurface 处理程序中创建或重新创建。 如果需要重新创建位图,现有位图的内容将复制到新位图,以便无论显示图面的大小如何变化,都会保留所有内容:

public partial class FingerPaintSavePage : ContentPage
{
    ···
    SKBitmap saveBitmap;

    public FingerPaintSavePage ()
    {
        InitializeComponent ();
    }

    void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
    {
        SKImageInfo info = args.Info;
        SKSurface surface = args.Surface;
        SKCanvas canvas = surface.Canvas;

        // Create bitmap the size of the display surface
        if (saveBitmap == null)
        {
            saveBitmap = new SKBitmap(info.Width, info.Height);
        }
        // Or create new bitmap for a new size of display surface
        else if (saveBitmap.Width < info.Width || saveBitmap.Height < info.Height)
        {
            SKBitmap newBitmap = new SKBitmap(Math.Max(saveBitmap.Width, info.Width),
                                              Math.Max(saveBitmap.Height, info.Height));

            using (SKCanvas newCanvas = new SKCanvas(newBitmap))
            {
                newCanvas.Clear();
                newCanvas.DrawBitmap(saveBitmap, 0, 0);
            }

            saveBitmap = newBitmap;
        }

        // Render the bitmap
        canvas.Clear();
        canvas.DrawBitmap(saveBitmap, 0, 0);
    }
    ···
}

PaintSurface 处理程序完成的绘图发生在最后,并且只包括呈现位图。

触摸处理与前面的程序很相似。 该程序维护两个集合(inProgressPathscompletedPaths),其中包含自上次清除显示以来用户绘制的所有内容。 对于每个触摸事件,OnTouchEffectAction 处理程序都会调用 UpdateBitmap

public partial class FingerPaintSavePage : ContentPage
{
    Dictionary<long, SKPath> inProgressPaths = new Dictionary<long, SKPath>();
    List<SKPath> completedPaths = new List<SKPath>();

    SKPaint paint = new SKPaint
    {
        Style = SKPaintStyle.Stroke,
        Color = SKColors.Blue,
        StrokeWidth = 10,
        StrokeCap = SKStrokeCap.Round,
        StrokeJoin = SKStrokeJoin.Round
    };
    ···
    void OnTouchEffectAction(object sender, TouchActionEventArgs args)
    {
        switch (args.Type)
        {
            case TouchActionType.Pressed:
                if (!inProgressPaths.ContainsKey(args.Id))
                {
                    SKPath path = new SKPath();
                    path.MoveTo(ConvertToPixel(args.Location));
                    inProgressPaths.Add(args.Id, path);
                    UpdateBitmap();
                }
                break;

            case TouchActionType.Moved:
                if (inProgressPaths.ContainsKey(args.Id))
                {
                    SKPath path = inProgressPaths[args.Id];
                    path.LineTo(ConvertToPixel(args.Location));
                    UpdateBitmap();
                }
                break;

            case TouchActionType.Released:
                if (inProgressPaths.ContainsKey(args.Id))
                {
                    completedPaths.Add(inProgressPaths[args.Id]);
                    inProgressPaths.Remove(args.Id);
                    UpdateBitmap();
                }
                break;

            case TouchActionType.Cancelled:
                if (inProgressPaths.ContainsKey(args.Id))
                {
                    inProgressPaths.Remove(args.Id);
                    UpdateBitmap();
                }
                break;
        }
    }

    SKPoint ConvertToPixel(Point pt)
    {
        return new SKPoint((float)(canvasView.CanvasSize.Width * pt.X / canvasView.Width),
                            (float)(canvasView.CanvasSize.Height * pt.Y / canvasView.Height));
    }

    void UpdateBitmap()
    {
        using (SKCanvas saveBitmapCanvas = new SKCanvas(saveBitmap))
        {
            saveBitmapCanvas.Clear();

            foreach (SKPath path in completedPaths)
            {
                saveBitmapCanvas.DrawPath(path, paint);
            }

            foreach (SKPath path in inProgressPaths.Values)
            {
                saveBitmapCanvas.DrawPath(path, paint);
            }
        }

        canvasView.InvalidateSurface();
    }
    ···
}

UpdateBitmap 方法通过创建新的 SKCanvas,将其清除,然后在位图上呈现所有路径来重新绘制 saveBitmap。 最后,它通过使 canvasView 失效,以便可在显示器上绘制位图。

下面是两个按钮的处理程序。 “清除”按钮会清除两个路径集合、更新 saveBitmap(这会清除位图),并使 SKCanvasView 失效:

public partial class FingerPaintSavePage : ContentPage
{
    ···
    void OnClearButtonClicked(object sender, EventArgs args)
    {
        completedPaths.Clear();
        inProgressPaths.Clear();
        UpdateBitmap();
        canvasView.InvalidateSurface();
    }

    async void OnSaveButtonClicked(object sender, EventArgs args)
    {
        using (SKImage image = SKImage.FromBitmap(saveBitmap))
        {
            SKData data = image.Encode();
            DateTime dt = DateTime.Now;
            string filename = String.Format("FingerPaint-{0:D4}{1:D2}{2:D2}-{3:D2}{4:D2}{5:D2}{6:D3}.png",
                                            dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Millisecond);

            IPhotoLibrary photoLibrary = DependencyService.Get<IPhotoLibrary>();
            bool result = await photoLibrary.SavePhotoAsync(data.ToArray(), "FingerPaint", filename);

            if (!result)
            {
                await DisplayAlert("FingerPaint", "Artwork could not be saved. Sorry!", "OK");
            }
        }
    }
}

“保存”按钮处理程序使用来自 SKImage 的简化的 Encode 方法。 此方法使用 PNG 格式进行编码。 SKImage 对象基于 saveBitmap 对象创建,并且 SKData 对象包含已编码的 PNG 文件。

ToArraySKData 方法会获取字节数组。 这是传递给 SavePhotoAsync 方法的内容,还会传递固定文件夹名称以及根据当前日期和时间构造的唯一文件名。

下面是正在执行操作的程序:

手指绘制并保存

示例中使用了非常类似的技术。 这也是一个手指绘制程序,只不过用户在旋转圆盘上绘制,然后在其他 4 个象限上复制设计。 当圆盘旋转时,手指绘制的颜色会发生变化:

旋转绘制

SpinPaint 类的“保存”按钮类似于手指绘制,因为它将图像保存为固定文件夹名称 (SpainPaint) 和根据日期和时间构造的文件名。