iCloud开发入门

简介

CloudKit是苹果公司推出的基于iCloud的远程数据存储服务。它为存储和使用用户的iCloud账户的后端存储服务共享应用数据提供了一种低成本的选择方案。

总体来看,CloudKit主要提供了两个组件:

  • 一个网络中心,用于管理记录类型和任何公共数据。
  • 一组API,用于在iCloud和设备之间传输数据。

CloudKitr的安全性是很高的。用户的私有数据受到完全保护,因为开发人员只能访问他们自己的私人数据库而不能查看任何其他用户的私人数据。

对于只运行于iOS平台的使用大量数据但不需要服务器端大量逻辑的应用程序来说,CloudKit是一个不错的选择。此外,CloudKit也可用于网络和服务器应用程序。

在这个CloudKit教程中,您将获得使用CloudKit的切身体验,即你需要创建一个名为BabiFüd的餐厅评级应用程序。

为何选择CloudKit?

你可能想知道,为什么要选择基于核心数据(Core Data)基础之上的CloudKit,而不是其他BaaS(后端即服务)产品,甚至是基于你自己的服务器?

原因有三:简单性,高信誉度和低成本。

简单性

不像其他的后端解决方案,CloudKit仅需要很少的设置。你不必选择、配置或安装服务器。此外,安全性和伸缩性也都由苹果公司来处理。

只需在苹果官方网站注册成为iOS开发者项目成员,您就拥有了使用CloudKit的资格。你不必注册额外的服务或创建新帐户。当您在自己的应用程序中启用CloudKit支持时,所有必要的服务器设置都将魔术般自动发生。

你不需要下载额外的库并对它们进行配置。CloudKit就像任何其他iOS框架一样导入。CloudKit框架本身通过提供一些针对常见操作的便利的API实现了一定程度的简单性。

这也方便了用户使用。由于在设备设置(甚至是设置结束进入应用程序时)时CloudKit使用的是输入的iCloud凭据,所以没有必要建立复杂的登录屏幕。只要用户登录,他们就可以无缝地开始使用你的应用程序。

高信誉度

CloudKit的另一个好处是,通过依靠苹果公司而不是应用程序开发人员,用户可以相信他们的数据的隐私性和安全性。CloudKit能够把您(开发人员)与用户数据隔离开来。

虽然在调试程序时无法访问可能令人沮丧,但另一个方面这也带来一定好处,因为你不必担心安全或说服用户其数据的安全性。如果一个应用程序用户信任iCloud,那么他们也可以信任作为开发人员的你。

低成本

最后,对于任何开发者来说,运行服务的成本也是一个巨大的投资。即使是最便宜的服务器主机也不能为小型、免费或廉价的应用程序提供低成本的解决方案。所以总是会有与运行应用程序相关的成本问题。

借助于CloudKit,你可以实现针对免费的公共数据的适量的存储和数据传输。在WWDC 2015视频的CloudKit新增功能(https://developer.apple.com/videos/play/wwdc2015/704/)中提供了非常详尽的收费解释。

上述所有这些优势,使得CloudKit服务成为Mac和iOS应用程序开发中更值得选择的解决方案。

BabiFüd项目功能介绍

本教程中提供的BabiFüd示例应用程序使用时下最标准的“速度餐厅”型应用程序风格。不是沿用传统式的基于食品质量、服务速度以及价格等评价标准,而是使用新型的儿童友好性进行评价。这包括设施更换、加高座椅和健康性食品选择等可用性标准。

应用程序包含四个选项卡:一个附近的餐馆列表;一个附近餐馆地图展示;用户生成注释和功能设置。附近的餐馆列表选项卡是您将在本教程中使用的唯一部分。当然,你现在就可以一瞥运行中的这个示例应用程序。

iCloud开发入门_第1张图片

开发过程中,我使用了一个模型类来支持这些视图,并封装对CloudKit的调用。其中,CloudKit对象称为记录(Record)。模型中主要的记录类型是一个Establishment,它代表了你的应用程序中不同的餐馆。

开始

首先,请下载本教程中的启动项目,地址是https://cdn2.raywenderlich.com/wp-content/uploads/2016/06/BabiFud-Cloudkit-Starter.zip。

你必须改变你的应用程序的资源标识符和团队类型,然后才能开始编码。为了从苹果公司获得必要的授权,您需要设置团队。拥有一个独特的资源标识符可以使得很多的事更容易操作。

现在,请使用Xcode打开工程BabiFud.xcodeproj。然后,从「Project Navigator」下选择BabiFud项目,然后选择「BabiFud target」。接下来,选择「General」选项卡,使用一些独特的字符串内容更换资源标识符(Bundle Identifier)。标准的做法是,使用反向域名符号并包括项目名称。然后,选择合适的团队(Team):

现在,你需要在你的应用程序中设置CloudKit支持,并创建一些容器来保存数据。

权限和容器

在向应用程序中添加任何数据之前,你需要一个容器来保存应用程序的记录。容器,其实仅是一个概念上的位置术语,对应于所有应用程序在服务器上的数据。它分为公共和私有数据库两个组。

要创建一个容器,你首先需要拥有启用您的应用程序的iCloud权限。现在,请从目标编辑器选择【Capabilities】选项卡。然后,把iCloud部分中的开关切换为ON。

iCloud开发入门_第2张图片

现在,Xcode可能会提示您输入与您的iOS开发者帐户相关联的苹果ID。如果是这样,那么根据要求输入即可。最后,通过勾选【Services】组中的CloudKit复选框来启用CloudKit。

这将创建一个名为iCloud.的默认容器,如下图所示:

iCloud开发入门_第3张图片

克服Xcode中iCloud安装错误

如果你在创建权限、构建项目或运行应用程序时看到任何警告或错误,并注意到Xcode抱怨容器ID;那么,下面提供一些故障排除提示,供您参考:

  • 如果有任何的警告或错误显示在【iCloud】部分中的【Steps】组中;那么,你可以尝试按下「Fix Issue」按钮。这可能需要反复操作好几次。

    iCloud开发入门_第4张图片

  • 应用程序的包ID和iCloud的容器要相匹配并存在于开发人员帐户中,这一点是很重要的。例如,如果包标识为“com..BabiFud”,那么,iCloud的容器名称应是“iCloud.”,再加上资源包ID“iCloud.com..BabiFud”。
  • iCloud的容器名称必须是唯一的,因为这是使用CloudKit访问数据的全球标识符。由于iCloud的容器名称包含了资源ID,所以该资源ID也必须是唯一的(这就是为什么它必须由com.raywendrelich.BabiFud进行修改的原因)。
  • 为了过权限这一关,应用程序/包ID显示于【Certificates】,【Identifiers】和【Profiles】的App ID部分。这意味着,用于签名应用程序的证书必须来自于设置的团队ID并且必须列出应用程序ID;这也就是iCloud的容器id。通常情况下,如果你登录了一个有效的开发帐户,Xcode能够自动地完成这一切。不幸的是,这有时会导致不同步情况。你可以通过一个新的ID刷新来重新开始,并使用iCloud的功能窗格更改CloudKit容器ID以便进行匹配。否则,要解决这个问题,你可能要编辑info.plist文件或BabiFud.entitlements文件,以确保ID值反映你为应用程序设置的包ID。

CloudKit控制面板简介

在创建完工程所必需的餐馆数据之后,下一步就是创建一些记录类型来定义您的应用程序所使用的数据。您可以使用CloudKit控制面板来实现这一点。从工程的【Capabilities】窗格中点击【CloudKit Dashboard】,如图所示。

iCloud开发入门_第5张图片

【注意】你还可以通过在浏览器中打开网址https://icloud.developer.apple.com/dashboard/ 来启动CloudKit控制面板。

这个控制面板是什么样子呢?请观察一下这个图形:

iCloud开发入门_第6张图片

注意到,该控制面板由四部分组成:架构(Schema),公共数据(Public Data),私有数据(Private Data)和管理员(Admin)。

其中,Schema部分代表CloudKit容器中的最高层次对象:记录类型(Record Types),安全角色(Security Roles)及订阅类型(Subscription Types)。在本教程中,你只要关心记录类型即可。

记录类型(Record Types),是一组定义了单个记录的字段。对于面向对象编程而言,一个记录类型就像一个类。一个记录可以被认为是一个特殊的记录类型的实例。它代表了容器中的结构化数据,就像数据库中的一个典型的行,它封装了一系列键/值对。

公共数据(PUBLIC DATA)和私有数据(PRIVATE DATA)部分,可以让您在您能够访问的数据库中添加数据或搜索数据。请记住,作为一名开发人员你可以访问所有的公共数据,但只能访问你自己的私有数据。其中,「User Records」存储关于当前iCloud用户的数据,如姓名和电子邮件等。「记录区域」(Record Zone)(这里使用的是默认的区域),用于提供一个逻辑组织到一个私有数据库;实现方法是对记录进行分组。注意,自定义区域支持原子事务,即允许在处理其它操作之前把多个记录同时保存。不过,有关自定义区域的探讨已经超出本教程的范围。

管理员(ADMIN)部分,能够针对您的团队成员提供不同的权限配置控制。如果你有多个开发团队成员,你可以在这里限制他们编辑数据的能力。当然,这也超出本教程范围。

添加餐馆记录类型

现在,请略微想一想你的应用程序的设计吧。你要跟踪的每一家餐馆都对应大量的数据:名称、位置以及各种儿童友好的设施的可用性,等等。记录类型使用字段来定义每个记录包含的各个部分中的数据。

选择记录类型(Record Types)后,单击详细信息窗格左上方的+图标添加一个新的记录类型,如图所示。

iCloud开发入门_第7张图片

命名你刚刚创建的新记录类型为「Establishment」。

你会看到出现一行字段,其中定义了字段名、字段类型和索引等,如下图所示。注意,有一个字段使用了默认名称「StringField」,这是系统自动为您创建的。

接下来,你要使用新名字「Name」替换「StringField」。字段类型和索引默认已匹配您所需要的第一个字段的定义,但接下来,你需要对于其他一些字段改变字段类型和索引。单击【Add Field…】并根据需要增加新的字段即可。最后,你需要添加以下字段:

当你添加完所有字段后,你的字段列表应该是这样的:

iCloud开发入门_第8张图片

点击页面底部【Save】按钮保存您的新的记录类型。

现在,您已经准备好向您的数据库中添加一些示例记录了。

选择左侧导航窗格中【PUBLIC DATA】下部的【Default Zone】。该区域将包含您的应用程序的所有公共记录。如果还没有选定,那么请从中心窗格的下拉列表中选择「Establishment」记录类型。然后单击右侧详细信息窗格中的+图标或【New Record】按钮,如下图所示:

iCloud开发入门_第9张图片

这将创建一个新的空的Establishment记录。

iCloud开发入门_第10张图片

此时,您已经准备好为您的应用程序输入一些测试数据了。

需要说明的是,下面的示例数据都是虚构的。这些餐馆数据都位于苹果总部附近,这样它们可以很容易地出现在模拟器上。

现在,请输入如下表所述的每个记录:

【注意】每个CoverPhoto元素对应的图像文件都包含在Xcode项目的「Supporting Files\Sample Images」文件夹中。要将图像添加到Establishment记录中,只需将其拖动到CoverPhoto字段中即可。

iCloud开发入门_第11张图片

一旦保存完所有三个记录,控制面板应该是这样的:

对于每个记录,输入的值都代表了数据的数据库描述部分。在应用程序中,数据类型是不同的。例如,SeatingType和ChangingTable都是结构类型的。所以,对于SeatingType字段指定的int值可能对应于“high chair”或“booster”这样的座位。对于HealthyOption和KidsMenu这两个字段指定的int值表示布尔类型:其中,0是指没有这一项,1则指有这一项。

最后,运行应用程序需要你有一个可以用于开发的iCloud帐户。请参考苹果官方文档https://developer.apple.com/library/tvos/documentation/DataManagement/Conceptual/CloudKitQuickStart/EnablingiCloudandConfiguringCloudKit/EnablingiCloudandConfiguringCloudKit.html#//apple_ref/doc/uid/TP40014987-CH2-SW7。

另外,您还需要在iPhone模拟器中输入与此帐户相关联的icloud凭据。请参考苹果官方文档https://developer.apple.com/library/tvos/documentation/DataManagement/Conceptual/CloudKitQuickStart/CreatingaSchemabySavingRecords/CreatingaSchemabySavingRecords.html#//apple_ref/doc/uid/TP40014987-CH3-SW12。

现在,切换回Xcode中。从下一节开始,你要把上面创建的数据集成到您的应用程序中!

查询餐馆记录

CKQuery对象用于从数据库中选择记录。CKQuery描述了如何找到符合特定条件的特定类型的所有记录。这些条件可以是这样的:所有记录都有一个以N开头的Name字段;所有记录都带有儿童增高座椅;所有记录都要满足在3公里以内。这些类型的表达式都通过NSPredicate对象编码于Cocoa库中。NSPredicate能够评估对象,看它们是否符合指定条件。NSPredicate也用在核心数据(Core Data)中,并且自然地融入CloudKit中,因为谓词通常被定义用于针对某个字段的比较方面。

事实上,CloudKit仅支持NSPredicate功能的一个子集。这些功能包括:数学比较,字符串和集合操作(例如“字段匹配列表中的项目之一”),还提供了一个特殊的距离函数。函数distanceToLocation:fromLocation:被专门添加到NSPredicate对象定义中,特别支持CloudKit以匹配带有位置字段的记录——此已知位置位于指定半径的范围内。接下来的内容中将详细介绍这种类型谓词的用法。对于其他类型的查询中,CKQuery类参考文档(https://developer.apple.com/library/ios/documentation/CloudKit/Reference/CKQuery_class/)中包含了有关其支持的功能及如何使用的详细描述。

【注意】CloudKit包括了对CLLocation对象的支持。有一些核心定位框架对象(Core Location Framework)包含了有关地理坐标的信息。这使得开发人员可以很容易创建一个查询来寻找指定地理区域内的餐馆——而不需要我们自己进行所有繁琐的坐标运算。

接下来,在Xcode中打开文件Model/ Model.swift。该文件中包含了所有您的应用程序进行服务器调用的存根。

现在,请使用下面的内容来更换fetchEstablishments(_:radiusInMeters:)方法:

 
  
  1. func fetchEstablishments(location:CLLocation, radiusInMeters:CLLocationDistance) { 
  2.   // 1 
  3.   let radiusInKilometers = radiusInMeters / 1000.0    
  4.   // 2 
  5.   let locationPredicate = NSPredicate(format: "distanceToLocation:fromLocation:(%K,%@) < %f", "Location", location, radiusInKilometers)    
  6.   // 3 
  7.   let query = CKQuery(recordType: EstablishmentType, predicate: locationPredicate)    
  8.   // 4 
  9.   publicDB.performQuery(query, inZoneWithID: nil) { [unowned self] results, error in 
  10.     if let errorerror = error { 
  11.       dispatch_async(dispatch_get_main_queue()) { 
  12.         self.delegate?.errorUpdating(error) 
  13.         print("Cloud Query Error - Fetch Establishments: \(error)") 
  14.       } 
  15.       return 
  16.     } 
  17.   
  18.     self.items.removeAll(keepCapacity: true) 
  19.     results?.forEach({ (record: CKRecord) in 
  20.       self.items.append(Establishment(record: record, 
  21.         database: self.publicDB)) 
  22.     }) 
  23.   
  24.     dispatch_async(dispatch_get_main_queue()) { 
  25.       self.delegate?.modelUpdated() 
  26.     } 
  27.   } 

现在,我们按编号来分析一下上面代码的功能:

1.    CloudKit在其距离谓词中利用公里为计算单位。这一行简单地把radiusInMeters转换为公里。

2.    根据从当前位置到他们的位置的距离谓词对餐馆进行筛选。这个语句使用从用户的当前位置到指定距离内的位置值来查找所有餐馆。

3.    使用谓词和记录类型创建CKQuery对象。执行查询时两者都将被使用。

4.    最后,方法performQuery(_:inZoneWithID:completionHandler :)负责发送查询到iCloud中,并等待任何匹配的结果。通过传递nil作为inZoneWithID参数值,可以针对你所在的默认区域进行查询;也就是说,针对你的公共数据库。如果你既想从公共数据库也想从私有数据库中检索记录,那么,你必须使用一个单独的调用来查询每个数据库。

你可能想知道:CKDatabase的实例publicDB从何而来?让我们来看看文件Model.swift的顶部的代码吧。

 
  
  1. let container: CKContainer 
  2. let publicDB: CKDatabase 
  3. let privateDB: CKDatabase 
  4.   
  5. init() { 
  6.   // 1 
  7.   container = CKContainer.defaultContainer() 
  8.   // 2 
  9.   publicDB = container.publicCloudDatabase 
  10.   // 3 
  11.   privateDB = container.privateCloudDatabase 

在这里,您定义了您的数据库:

1.    默认容器是指您在iCloud的功能窗格中指定的那个。

2.    公共数据库是指在你的应用程序的所有用户中共享的那个。

3.    私有数据库只包含属于当前登录的用户(在本实例中即指你)的数据。

总之,此代码将从公共数据库中检索一些地方餐馆,但为了在任何应用程序中看到相应的数据,必须把它关联到一个视图控制器。

创建需求回调函数

你可以借助熟悉的委托模式来管理通知的问题。下面这个协议位于Model.swift文件的顶部;当然,你需要在你的视图控制器中实现这个协议:

 
  
  1. protocol ModelDelegate { 
  2.   func errorUpdating(error: NSError) 
  3.   func modelUpdated() 
  4. 现在,打开文件MasterViewController.swift,并用以下内容替换modelUpdated()方法原有内容: 
  5. func modelUpdated() { 
  6.   refreshControl?.endRefreshing() 
  7.   tableView.reloadData() 

当新数据可用时此方法会被调用。在方法tableView(_:cellForRowAtIndexPath:)中实现了所有关于表格视图单元格与CloudKit对象绑定的代码。您可以自行分析学习,恕在此不再赘述。

接下来,在文件MasterViewController.swift中,请使用如下内容更换errorUpdating(_ :)方法:

 
  
  1. func errorUpdating(error: NSError) { 
  2.     let alertController = UIAlertController(title: nil, 
  3.                                             message: error.localizedDescription, 
  4.                                             preferredStyle: .Alert) 
  5.     alertController.addAction(UIAlertAction(title: "Dismiss", 
  6.                                             style: .Default, 
  7.                                             handler: nil)) 
  8.     presentViewController(alertController, animated: true, completion: nil) 

当查询产生错误时调用此方法。可能由于网络条件较差或特定CloudKit问题(如丢失或不正确的用户凭据或没有记录从查询返回等)发生错误。

当处理任何一种远程服务器连接时,良好的错误处理是必不可少的。现在,这段代码只显示给用户返回的错误信息。

但是,目前程序中存在的非常普遍的问题是:一个是用户不能登录到iCloud;另一个是程序还不具有支持用户自动登录icloud的功能。建议您自己修改一下errorUpdating(_:)方法使之至少能够处理这些情况。提示:目前这两类错误都返回1(CKErrorCode)。

现在,请构建和运行示例项目。目前,你应该看到一个几近完整的餐馆单位名单列表了。

iCloud开发入门_第12张图片

查询排错

如果你正在使用iOS模拟器但却发现列表控件中没有填充任何内容,那么,你务必确保从Xcode的菜单项【Debug\Simulate Location\San Francisco, CA, USA】下设置了正确的位置。如果您需要在Xcode中改变这一位置,那么你可以从应用程序的下拉列表中选择其他位置,从而实现强制刷新而不是被动地等待位置触发。

如果您使用的是iPhone或iPad且启用了定位服务而列表仍未填充,那么说明餐馆没有足够接近你当前的位置。此时,你有两个选择:改变样本数据的坐标以便更接近您的当前位置;或者使用模拟器来运行应用程序。还有第三个选择,但不是非常实用,即你不得不前往库比提诺(Cupertino)并进入苹果公司地区进行试验。

如果数据未显示正确——或者根本不显示,那么,请使用CloudKit控制面板检查样本数据。确保所有的记录都存在,而且你已经将它们添加到默认区域,而且它们的值都是正确的。如果您需要重新输入数据,那么你可以通过单击回收站图标删除记录(参考下图)。

iCloud开发入门_第13张图片

调试CloudKit错误有时可能非常棘手。在撰写本文时,CloudKit错误消息中并不包含大量信息。要确定错误的原因,您需要查看与你的特定数据库操作相关连的错误代码。使用数字错误代码,查找相匹配的CKErrorCode枚举值进行分析。在文档中的名称和说明将帮助你缩小问题原因的分析范围。

【注意】对于可通过CloudKit返回的错误代码列表,请参阅CloudKit框架常量参考(https://developer.apple.com/library/ios/documentation/CloudKit/Reference/CloudKit_constants/#//apple_ref/c/tdef/CKErrorCode)。

下面是一些常见的错误枚举和相关说明:

  • BadContainer——指定的容器是未知的或未经授权的。
  • NotAuthenticated——当前用户没有通过验证,也没有用户记录可用。如果用户未登录到iCloud时可能出现这种情况。
  • UnknownItem——指定的记录不存在。

当你得到餐馆的名单列表时,你可能已经注意到了,你可以看到服务餐馆名称及其提供的服务。但没有显示图像!是云端那边出问题了吗?

当您检索服务餐馆记录时,将自动检索对应的图像。但是,您仍然需要执行必要的步骤来将图像加载到你的应用程序中。这需要借助于云端方法了!

使用二进制资源

对于二进制形式的资源数据,如图像,都有相关联的记录。在本文实例中,你的应用程序的资源数据都会显示于MasterViewController表视图的餐馆照片中。

在本节中,您需要添加逻辑来加载当检索餐馆记录时已下载的资源。

为此,打开文件Model/Establishment.swift,并用下面的代码更换loadCoverPhoto(_ :)方法:

 
  
  1. func loadCoverPhoto(completion:(photo: UIImage!) -> ()) { 
  2.   // 1 
  3.   dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0)) { 
  4.     var image: UIImage! 
  5.     // 4 
  6.     defer { 
  7.       completion(photo: image) 
  8.     } 
  9.     // 2 
  10.     guard let asset = self.record["CoverPhoto"] as? CKAsset, 
  11.       path = asset.fileURL.path, 
  12.       imageData = NSData(contentsOfFile: path) else { 
  13.         return 
  14.     } 
  15.     // 3 
  16.     image = UIImage(data: imageData) 
  17.   } 

此方法从asset有关属性中加载相关图片:

1.    虽然你下载了资源,但同时您还检索了记录的其余部分数据,因此,你需要使用异步方式加载图像。如你所见,我们把有关代码全部封装在dispatch_async代码块内。

2.    资源存储在CKRecord中,作为CKAsset的实例,所以需要相应地转换一下。接下来,从资源提供的本地文件URL中加载图像数据。

3.    使用图像数据来创建UIImage的一个实例。

4.    执行与检索的图像相关的completion回调函数。请注意,不管执行哪一个return语句,延迟(defer)块都被执行。例如,如果没有图像资源,那么,在返回时永远不会设置image变量的值,当然也就不显示餐厅对应的图像。

现在,请构建和运行示例项目。你会注意到,由于上面的云端操作,现在餐馆的图像显示出来了!

iCloud开发入门_第14张图片

目前,在CloudKit资源中还存在两个不足:

  • 资源只能在CloudKit中作为记录属性存在,你不能把它们单独存储。删除记录也会删除所有相关资源。
  • 因为资源与记录数据的其余部分在同一时间下载,所以,获取资源会产生一定的负面性能影响。如果您的应用程序使用了大量的资源,那么,你应该专门存储一下拥有资源的不同类型记录的引用。

总结

到现在为止,你应该已经看到了我们的最终项目中所提供的功能。目前,该应用程序已经可以下载餐馆记录,并把它们的详细资料和照片加载到程序的表视图中。

实际上,您还可以从以下几个方面进一步增强本文中的示例应用:

允许用户添加自己的照片、笔记、评论和投诉。这将有助于他们避免再次遭遇不愉快的经历。

允许用户使用地图创建一个新的餐馆记录。你可以把这样的功能添加到Model类中,用于把相应记录保存到公共或私有数据库中。

添加过滤和搜索支持。工程提供的Model类中已经构建了一个带有一个距离谓词的CKQuery,可以把它修改为一个更复杂的谓词。当然,CloudKit也是支持基于字符串字段的文本搜索的。

提高应用程序的性能和数据加载体验。你会注意到,本教程中在一切准备就绪时使用了一些工具方法来调用所需的完成处理器函数。另外,CKDatabase的实例也提供了基于NSOperation的方法来更好地控制API执行方式。

为程序提供缓存和同步支持;这样当应用程序连接到网络时,它能够保持线下响应并保持内容最新。

使用苹果公司推出的有着强大后端API支持的CloudKit,相信你能够把你的应用程序提升到一个更高的水平!

你可能感兴趣的:(iCloud开发入门)