将 iCloud 与 Xamarin.iOS 配合使用

iOS 5 中的 iCloud 存储 API 允许应用程序将用户文档和特定于应用程序的数据保存到中心位置,并从所有用户的设备访问这些项目。

有四种类型的可用存储:

  • 键值存储 - 与用户的其他设备上的应用程序共享少量数据。

  • UIDocument 存储 - 使用 UIDocument 的子类将文档和其他数据存储在用户的 iCloud 帐户中。

  • CoreData - SQLite 数据库存储。

  • 单独的文件和目录 - 用于直接在文件系统中管理大量不同的文件。

本文档讨论前两种类型 - 键值对和 UIDocument 子类 - 以及如何在 Xamarin.iOS 中使用这些功能。

重要

Apple 提供工具,用于帮助开发人员正确处理欧盟一般数据保护条例 (GDPR)。

要求

  • Xamarin.iOS 的最新稳定版本
  • Xcode 10
  • Visual Studio for Mac 或 Visual Studio 2019。

准备 iCloud 开发

必须将应用程序配置为在 Apple 预配门户和项目本身中使用 iCloud。 在针对 iCloud 进行开发(或尝试示例)之前,请按照以下步骤操作。

若要正确配置一个用于访问 iCloud 的应用程序,请执行以下操作:

  • 查找 TeamID - 登录 developer.apple.com 并访问“会员中心”>“你的帐户”>“开发人员帐户摘要”以获取团队 ID(若是单个开发人员,则为个人 ID)。 它将是一个 10 个字符的字符串(例如 A93A5CM278) - 构成“容器标识符”的一部分。

  • 创建新的应用 ID - 若要创建应用 ID,请按照“设备预配”指南的“存储技术预配”部分中概述的步骤操作,并务必选中“iCloud”作为允许的服务:

Check iCloud as an allowed service

  • 创建新的预配配置文件 - 若要创建预配配置文件,请按照“设备预配”指南中概述的步骤操作。

  • 将容器标识符添加到 Entitlements.plist - 容器标识符格式为 TeamID.BundleID。 有关详细信息,请参阅使用权利指南。

  • 配置项目属性 - 在 Info.plist 文件中,确保“捆绑包标识符”创建应用 ID 时设置的“捆绑包 ID”匹配;iOS 捆绑包签名使用一个“预配配置文件”,其中包含具有 iCloud 应用服务的应用 ID 以及所选的“自定义权利”文件。 这一切都可以在 Visual Studio 中的项目属性窗格下完成。

  • 在设备上启用 iCloud - 转到“设置”>“iCloud”,确保设备已登录。 选择并打开“文档和数据”选项。

  • 必须使用设备来测试 iCloud - 它无法在模拟器上运行。 事实上,你确实需要至少两台设备全都使用同一 Apple ID 登录才能看到 iCloud 的运行情况。

键值存储

键值存储适用于用户可能希望将其跨设备保存的少量数据,例如用户在书籍或杂志中看到的最后一页。 键值存储不应该用于备份数据。

使用键值存储时需要注意一些限制:

  • 最大键大小 - 键名不能超过 64 字节。

  • 最大值大小 - 单个值中存储的大小不能超过 64 KB。

  • 应用的最大键值存储大小 - 应用程序总共最多只能存储 64 KB 的键值数据。 如果尝试设置超出该限制的键,则会遭遇失败,但先前的值会保留。

  • 数据类型 - 只能存储字符串、数字和布尔值等基本类型。

iCloudKeyValue 示例演示了其工作原理。 示例代码为每个设备创建一个命名的键:你可以在一个设备上设置此键,然后观察该值传播到其他设备。 它还创建一个名为“共享”的键,该键可以在任何设备上进行编辑 - 如果你同时在多个设备上进行编辑,则由 iCloud 决定哪个值有效(依据该更改对应的时间戳)并让其进行传播。

此屏幕截图显示了正在使用的示例。 从 iCloud 收到更改通知时,这些通知会输出到屏幕底部的滚动文本视图中,并在输入字段中获得更新。

The flow of messages between devices

设置和检索数据

以下代码演示如何设置字符串值。

var store = NSUbiquitousKeyValueStore.DefaultStore;
store.SetString("testkey", "VALUE IN THE CLOUD");  // key and value
store.Synchronize();

调用 Synchronize 可确保该值仅保留到本地磁盘存储。 到 iCloud 的同步发生在后台,不能由应用程序代码“强制执行”。 如果网络连接良好,同步通常会在 5 秒内完成,但如果网络较差(或已断开连接),则更新可能需要长得多的时间。

可以使用以下代码检索值:

var store = NSUbiquitousKeyValueStore.DefaultStore;
display.Text = store.GetString("testkey");

该值是从本地数据存储中检索的 - 此方法不会尝试联系 iCloud 服务器来获取“最新”值。 iCloud 会根据其自己的计划更新本地数据存储。

删除数据

若要完全删除键值对,请使用 Remove 方法,如下所示:

var store = NSUbiquitousKeyValueStore.DefaultStore;
store.Remove("testkey");
store.Synchronize();

观察更改

通过向 NSNotificationCenter.DefaultCenter 添加观察程序,应用程序还可以在 iCloud 更改值时收到通知。 KeyValueViewController.csViewWillAppear 方法中的以下代码显示了如何侦听这些通知并创建已更改键的列表:

keyValueNotification =
NSNotificationCenter.DefaultCenter.AddObserver (
    NSUbiquitousKeyValueStore.DidChangeExternallyNotification, notification => {
    Console.WriteLine ("Cloud notification received");
    NSDictionary userInfo = notification.UserInfo;

    var reasonNumber = (NSNumber)userInfo.ObjectForKey (NSUbiquitousKeyValueStore.ChangeReasonKey);
    nint reason = reasonNumber.NIntValue;

    var changedKeys = (NSArray)userInfo.ObjectForKey (NSUbiquitousKeyValueStore.ChangedKeysKey);
    var changedKeysList = new List<string> ();
    for (uint i = 0; i < changedKeys.Count; i++) {
        var key = changedKeys.GetItem<NSString> (i); // resolve key to a string
        changedKeysList.Add (key);
    }
    // now do something with the list...
});

然后,代码可以对已更改键的列表执行某些操作,例如更新其本地副本或使用新值更新 UI。

可能的更改原因有:ServerChange (0)、InitialSyncChange (1) 或 QuotaViolationChange (2)。 你可以访问原因并根据需要执行不同的处理(例如,你可能需要因 QuotaViolationChange 而删除一些键)。

文档存储

iCloud 文档存储旨在管理对应用(和用户)重要的数据。 它可用于管理应用运行所需的文件和其他数据,同时在所有用户设备上提供基于 iCloud 的备份和共享功能。

下图显示了这一切是如何协作的。 每个设备都将数据保存在本地存储 (UbiquityContainer) 中,操作系统的 iCloud 守护程序负责在云端发送和接收数据。 对 UbiquityContainer 的所有文件访问都必须通过 FilePresenter/FileCoordinator 完成,以防止并发访问。 UIDocument 类为你实现这些;这个例子展示了如何使用 UIDocument。

The document storage overview

iCloudUIDoc 示例实现了一个简单的 UIDocument 子类,其中包含单个文本字段。 文本在 UITextView 中呈现,编辑内容由 iCloud 传播到其他设备,通知消息显示为红色。 示例代码不处理解决冲突之类的更高级 iCloud 功能。

以下屏幕截图显示了示例应用程序 - 更改文本并按 UpdateChangeCount 后,文档将通过 iCloud 同步到其他设备。

This screenshot shows the sample application after changing the text and pressing UpdateChangeCount

iCloudUIDoc 示例有五个部分:

  1. 访问 UbiquityContainer - 确定 iCloud 是否已启用,如果已启用,则确定应用程序的 iCloud 存储区域的路径。

  2. 创建 UIDocument 子类 - 创建一个类来充当 iCloud 存储和你的模型对象之间的中介。

  3. 找到并打开 iCloud 文档 - 使用 NSFileManagerNSPredicate 找到并打开 iCloud 文档。

  4. 显示 iCloud 文档 - 公开 UIDocument 中的属性,以便你可以与 UI 控件进行交互。

  5. 保存 iCloud 文档 - 确保在 UI 中所做的更改保存到磁盘和 iCloud。

所有 iCloud 操作都异步运行(或应该异步运行),因此,它们在等待要发生的操作时不会造成阻塞。 你将在示例中看到实现此目的的三种不同方法:

线程 - 在 AppDelegate.FinishedLaunching 中,对 GetUrlForUbiquityContainer 的初始调用是在另一个线程上完成的,目的是防止阻塞主线程。

NotificationCenter - 在异步操作(例如 NSMetadataQuery.StartQuery)完成时注册通知。

完成事件处理器 - 传入要在完成异步操作(如 UIDocument.Open)时运行的方法。

访问 UbiquityContainer

使用 iCloud 文档存储的第一步是确定 iCloud 是否已启用,如果已启用,则确定“ubiquity 容器”的位置(设备上存储启用了 iCloud 的文件的目录)。

此代码位于示例的 AppDelegate.FinishedLaunching 方法中。

// GetUrlForUbiquityContainer is blocking, Apple recommends background thread or your UI will freeze
ThreadPool.QueueUserWorkItem (_ => {
    CheckingForiCloud = true;
    Console.WriteLine ("Checking for iCloud");
    var uburl = NSFileManager.DefaultManager.GetUrlForUbiquityContainer (null);
    // OR instead of null you can specify "TEAMID.com.your-company.ApplicationName"

    if (uburl == null) {
        HasiCloud = false;
        Console.WriteLine ("Can't find iCloud container, check your provisioning profile and entitlements");

        InvokeOnMainThread (() => {
            var alertController = UIAlertController.Create ("No \uE049 available",
            "Check your Entitlements.plist, BundleId, TeamId and Provisioning Profile!", UIAlertControllerStyle.Alert);
            alertController.AddAction (UIAlertAction.Create ("OK", UIAlertActionStyle.Destructive, null));
            viewController.PresentViewController (alertController, false, null);
        });
    } else { // iCloud enabled, store the NSURL for later use
        HasiCloud = true;
        iCloudUrl = uburl;
        Console.WriteLine ("yyy Yes iCloud! {0}", uburl.AbsoluteUrl);
    }
    CheckingForiCloud = false;
});

Apple 建议在应用进入前台时调用 GetUrlForUbiquityContainer,尽管该示例没有这样做。

创建 UIDocument 子类

所有 iCloud 文件和目录(即存储在 UbiquityContainer 目录中的任何内容)都必须使用 NSFileManager 方法进行管理,实现 NSFilePresenter 协议并通过 NSFileCoordinator 进行写入。 完成所有这些操作的最简单方法不是你自己编写它,而是由子类 UIDocument 来为你完成这一切。

只需在 UIDocument 子类中实现两个方法即可使用 iCloud:

  • LoadFromContents - 传入文件内容的 NSData,方便你将其解压到模型类中。

  • ContentsForType - 请求你提供模型类的 NSData 表示形式,以便将其保存到磁盘(和云)。

iCloudUIDoc\MonkeyDocument.cs 中的以下示例代码展示了如何实现 UIDocument。

public class MonkeyDocument : UIDocument
{
    // the 'model', just a chunk of text in this case; must easily convert to NSData
    NSString dataModel;
    // model is wrapped in a nice .NET-friendly property
    public string DocumentString {
        get {
            return dataModel.ToString ();
        }
        set {
            dataModel = new NSString (value);
        }
    }
    public MonkeyDocument (NSUrl url) : base (url)
    {
        DocumentString = "(default text)";
    }
    // contents supplied by iCloud to display, update local model and display (via notification)
    public override bool LoadFromContents (NSObject contents, string typeName, out NSError outError)
    {
        outError = null;

        Console.WriteLine ("LoadFromContents({0})", typeName);

        if (contents != null)
            dataModel = NSString.FromData ((NSData)contents, NSStringEncoding.UTF8);

        // LoadFromContents called when an update occurs
        NSNotificationCenter.DefaultCenter.PostNotificationName ("monkeyDocumentModified", this);
        return true;
    }
    // return contents for iCloud to save (from the local model)
    public override NSObject ContentsForType (string typeName, out NSError outError)
    {
        outError = null;

        Console.WriteLine ("ContentsForType({0})", typeName);
        Console.WriteLine ("DocumentText:{0}",dataModel);

        NSData docData = dataModel.Encode (NSStringEncoding.UTF8);
        return docData;
    }
}

本例中的数据模型非常简单 - 单个文本字段。 数据模型的复杂程度取决于需要,例如,可以是 XML 文档,也可以是二进制数据。 UIDocument 实现的主要作用是在模型类和可以在磁盘上保存/加载的 NSData 表示形式之间进行转换。

找到并打开 iCloud 文档

示例应用仅处理单个文件 - test.txt,因此 AppDelegate.cs 中的代码会创建一个 NSPredicateNSMetadataQuery 来专门查找该文件名。 NSMetadataQuery 以异步方式运行,在完成时会发送通知。 通知观察程序会调用 DidFinishGathering,后者会停止查询并调用 LoadDocument,而 LoadDocument 则使用带完成事件处理器的 UIDocument.Open 方法来尝试加载文件并将其显示在 MonkeyDocumentViewController 中。

string monkeyDocFilename = "test.txt";
void FindDocument ()
{
    Console.WriteLine ("FindDocument");
    query = new NSMetadataQuery {
        SearchScopes = new NSObject [] { NSMetadataQuery.UbiquitousDocumentsScope }
    };

    var pred = NSPredicate.FromFormat ("%K == %@", new NSObject[] {
        NSMetadataQuery.ItemFSNameKey, new NSString (MonkeyDocFilename)
    });

    Console.WriteLine ("Predicate:{0}", pred.PredicateFormat);
    query.Predicate = pred;

    NSNotificationCenter.DefaultCenter.AddObserver (
        this,
        new Selector ("queryDidFinishGathering:"),
        NSMetadataQuery.DidFinishGatheringNotification,
        query
    );

    query.StartQuery ();
}

[Export ("queryDidFinishGathering:")]
void DidFinishGathering (NSNotification notification)
{
    Console.WriteLine ("DidFinishGathering");
    var metadataQuery = (NSMetadataQuery)notification.Object;
    metadataQuery.DisableUpdates ();
    metadataQuery.StopQuery ();

    NSNotificationCenter.DefaultCenter.RemoveObserver (this, NSMetadataQuery.DidFinishGatheringNotification, metadataQuery);
    LoadDocument (metadataQuery);
}

void LoadDocument (NSMetadataQuery metadataQuery)
{
    Console.WriteLine ("LoadDocument");

    if (metadataQuery.ResultCount == 1) {
        var item = (NSMetadataItem)metadataQuery.ResultAtIndex (0);
        var url = (NSUrl)item.ValueForAttribute (NSMetadataQuery.ItemURLKey);
        doc = new MonkeyDocument (url);

        doc.Open (success => {
            if (success) {
                Console.WriteLine ("iCloud document opened");
                Console.WriteLine (" -- {0}", doc.DocumentString);
                viewController.DisplayDocument (doc);
            } else {
                Console.WriteLine ("failed to open iCloud document");
            }
        });
    } // TODO: if no document, we need to create one
}

显示 iCloud 文档

显示 UIDocument 不应该与任何其他模型类有任何不同 - 属性显示在 UI 控件中,可能由用户编辑,然后写回模型。

在示例中,iCloudUIDoc\MonkeyDocumentViewController.csUITextView 中显示 MonkeyDocument 文本。 ViewDidLoad 侦听在 MonkeyDocument.LoadFromContents 方法中发送的通知。 当 iCloud 有文件的新数据时,会调用 LoadFromContents,以便通知指示文档已更新。

NSNotificationCenter.DefaultCenter.AddObserver (this,
    new Selector ("dataReloaded:"),
    new NSString ("monkeyDocumentModified"),
    null
);

示例代码通知处理器调用一个方法来更新 UI - 在本例中没有任何冲突检测或解决方法。

[Export ("dataReloaded:")]
void DataReloaded (NSNotification notification)
{
    doc = (MonkeyDocument)notification.Object;
    // we just overwrite whatever was being typed, no conflict resolution for now
    docText.Text = doc.DocumentString;
}

保存 iCloud 文档

若要将 UIDocument 添加到 iCloud,可以直接调用 UIDocument.Save(仅适用于新文档),也可以使用 NSFileManager.DefaultManager.SetUbiquitious 移动现有文件。 示例代码使用此代码直接在 ubiquity 容器中创建一个新文档(这里有两个完成事件处理器,一个用于 Save 操作,另一个用于 Open):

var docsFolder = Path.Combine (iCloudUrl.Path, "Documents"); // NOTE: Documents folder is user-accessible in Settings
var docPath = Path.Combine (docsFolder, MonkeyDocFilename);
var ubiq = new NSUrl (docPath, false);
var monkeyDoc = new MonkeyDocument (ubiq);
monkeyDoc.Save (monkeyDoc.FileUrl, UIDocumentSaveOperation.ForCreating, saveSuccess => {
Console.WriteLine ("Save completion:" + saveSuccess);
if (saveSuccess) {
    monkeyDoc.Open (openSuccess => {
        Console.WriteLine ("Open completion:" + openSuccess);
        if (openSuccess) {
            Console.WriteLine ("new document for iCloud");
            Console.WriteLine (" == " + monkeyDoc.DocumentString);
            viewController.DisplayDocument (monkeyDoc);
        } else {
            Console.WriteLine ("couldn't open");
        }
    });
} else {
    Console.WriteLine ("couldn't save");
}

对文档的后续更改不会直接“保存”,我们会改为告知 UIDocument 它已随 UpdateChangeCount 更改,并且它会自动安排一项保存到磁盘操作:

doc.UpdateChangeCount (UIDocumentChangeKind.Done);

管理 iCloud 文档

用户可以通过“设置”在应用程序外部的“ubiquity 容器”的 Documents 目录中管理 iCloud 文档;可以查看文件列表并执行滑动删除操作。 应用程序代码应该能够处理文档被用户删除的情况。 不要将内部应用程序数据存储在 Documents 目录中。

Managing iCloud Documents workflow

尝试从设备中删除已启用 iCloud 的应用程序时,用户还会收到不同的警告,这些警告会告知用户与该应用程序相关的 iCloud 文档的状态。

Screenshot shows a warning for Document Updates Pending.

Screenshot shows a warning for Delete i Cloud.

iCloud 备份

虽然备份到 iCloud 不是一项由开发人员直接访问的功能,但你设计应用程序的方式可能会影响用户体验。 Apple 提供了 iOS 数据存储指南,供开发人员在其 iOS 应用程序中遵循。

最重要的考虑因素是应用是否存储非用户生成的大型文件(例如,每期存储一百多兆字节内容的杂志阅读器应用程序)。 Apple 不希望你将此类数据存储在一个会将数据备份到 iCloud 并因而会不必要地消耗用户的 iCloud 配额的位置。

存储大量此类数据的应用程序应将其存储在未备份的用户目录之一(例如 Caches 或 tmp)中,或者使用 NSFileManager.SetSkipBackupAttribute 对这些文件应用一个标志,使 iCloud 在备份操作过程中忽略它们。

总结

本文介绍了 iOS 5 中包含的新 iCloud 功能。 本文检查了将项目配置为使用 iCloud 所需的步骤,然后提供了有关如何实现 iCloud 功能的示例。

键值存储示例演示了如何使用 iCloud 来存储少量数据,类似于存储 NSUserPreference 的方式。 UIDocument 示例展示了如何通过 iCloud 在多个设备之间存储和同步更复杂的数据。

最后简要讨论了添加 iCloud 备份会如何影响应用程序设计。