iCloud开发实践

写在前面

最近在一直在研究iCloud开发相关的东西,觉得是有必要写篇总结来整理一下近段时间的一些学习成果。之前一直听说iCloud服务不友好也不完善,开发难度也相对较大,其实个人觉得貌似也没说错,iCloud在客户端提供的框架相比于其他的功能框架来说,他是被分散到各个框架中,要使用它必须要了解各个部分的功能及使用场合,这样就增加了学习的成本。同时,框架的设计也比较松散,特别是文档同步,虽然提供了灵活功能强大的框架,但是要理清楚还是需要时间去学习和实践。

为了让大家少走弯路,我将iCloud划分了几大功能模块,下面会逐个地讲述每个模块的一些基本使用,同时配备例子进行说明:

准备工作

想要使用iCloud服务我们必须要有一个苹果的开发者账号(99$个人或者企业都可以),然后需要为项目进行一些配置:

  1. 在Xcode中点击项目目录结构的根节点进入项目设置
  2. 在Capabilities页签中找到iCloud一项,然后将对应该项的开关设置为开启状态。
  3. 在iCloud一栏下的有Services和Containers两个小栏目,其中Services中有三个选项,其对应说明如下:
名称 说明
Key-value storage 键值对的存储服务,用于一些简单的数据存储
iCloud Documents 文档存储服务,用于将文件保存到iCloud中
CloudKit 云端数据库服务

这三种服务会在后续章节为大家详细进行讲解。然后就是Container这一栏,顾名思义,其实可以简单认为他是用于存放数据的地方,因为每个应用所存放的数据应该是独立的同时也具有沙箱的限制,所以iOS为每个应用开辟了一个独立的空间来存放在iCloud的文件或数据,同时也方便从iCloud上同步数据到这个地方。默认情况下,一旦开启iCloud服务,就会创建一个默认的容器,其命名为iCloud + BundleID。如果你不想要使用默认的容器又或者想跟自己开发的其他App共享文件数据,则可以选择Specify custom containers选项,然后在容器列表中选择一个指定的容器,或者点击+号创建一个新的容器。

配置完成后,效果如图所示:

开启iCloud服务

注意:iCloud下面的Steps必须都打上勾才表示正常启用服务,否则需要根据提示检查你的苹果开发者账号中的一些应用设置。

在正式开始前还有一个事情要说清楚的,这里仅仅讨论的是使用iCloud作为登录账号的app,如果你的app有自己的用户系统,那么你还需要将同步的数据进行标识(例如加个系统用户标识来确定那份数据是哪个用户的),然后根据标识进行数据合并。好了,下面可以开始讲述一些具体开发过程(敲代码时间到了~)

Key-value同步

该种方式一般用于同步少量数据或者进行一些配置性质的数据同步。其使用也比较简单,iOS提供了一个NSUbiquitousKeyValueStore的类型来实现相关的操作。它的使用跟NSUserDefaults类似。主要提供以下的功能:

名称 说明
defaultStore 返回NSUbiquitousKeyValueStore对象,用于Key-value的存取操作
objectForKey: 获取指定key的值
setObject:forKey: 设置指定key的值
removeObjectForKey: 移除指定键值
stringForKey: 获取指定key保存的字符串,如果指定key不存在或者对应key保存的值不是NSString类型时则返回nil
setString:forKey: 为指定key设置一个字符串
arrayForKey: 获取指定key保存的数组,如果指定key不存在或者对应key保存的值不是NSArray类型时则返回nil
setArray:forKey: 为指定key设置一个数组对象
dictionaryForKey: 获取指定key保存的字典,如果指定key不存在或者对应key保存的值不是NSDictionary类型时则返回nil
setDictionary:forKey: 为指定key设置一个字典对象
dataForKey: 获取指定key保存的二进制数组,如果指定key不存在或者对应key保存的值不是NSData类型时则返回nil
setData:forKey: 为指定key设置一个二进制数组
longLongForKey: 获取指定key保存的64位整型值,如果指定key不存在或者对应key保存的值不包含数值时则返回0
setLongLong:forKey: 为指定key设置一个64位整型值
doubleForKey: 获取指定key保存的浮点数值,如果指定key不存在或者对应key保存的值不包含数值时则返回0
setDouble:forKey: 为指定key设置一个浮点数值
boolForKey: 获取指定key保存的布尔值,如果指定key不存在则返回NO
setBool:forKey: 为指定key设置一个布尔值
synchronize 同步数据,将在内存中的数据同步到磁盘中,并上传至iCloud
dictionaryRepresentation 该属性会返回载入到内存中保存的key-value字典,如果想要最新的数据则需要先调用synchronize方法

下面我们来举个例子,看看如何使用NSUbiquitousKeyValueStore进行数据同步。

首先,我们在界面中拖入两个按钮,一个用于设置数据,一个用于获取数据,如图所示:

Key-value Storage演示界面

然后在VC中声明一个NSUbiquitousKeyValueStore类型的属性,并且把两个按钮与VC的按钮点击事件进行关联,其中VC代码如下:

@interface KeyValueViewController ()

// Key-value同步数据存储对象
@property (nonatomic, strong) NSUbiquitousKeyValueStore *keyValueStore;

@end

@implementation KeyValueViewController

- (IBAction)setValueButtonClickedHandler:(id)sender
{
    // 设置值按钮点击事件
}

- (IBAction)getValueButtonClickedHandler:(id)sender
{
    // 获取值按钮点击事件
}

然后在viewDidLoad方法中对keyValueStore进行初始化:

- (void)viewDidLoad
{
    [super viewDidLoad];
    self.keyValueStore = [NSUbiquitousKeyValueStore defaultStore];
}

然后分别实现两个按钮的点击事件:

- (IBAction)setValueButtonClickedHandler:(id)sender
{
    [self.keyValueStore setString:@"Hello iCloud" forKey:@"data"];
    [self.keyValueStore synchronize];
}

- (IBAction)getValueButtonClickedHandler:(id)sender
{
    NSString *string = [self.keyValueStore stringForKey:@"data"];
    NSLog(@"data = %@", string);
}

上面的代码用到了NSUbiquitousKeyValueStore的字符串存取方法,要注意的是当你设置了数据后一定要调用synchronize方法,否则这些设置操作是不会保存下来并且上传到iCloud上的。

该例子最好是能够准备两台设备(或模拟器)来进行测试,一台进行值设置,另外一台进行值的获取。

有时候,我们需要实时知道一些配置的变更,特别是在你有多台设备时(如同时拥有iPhone和iPad),想要在其中一台设备中变更某项信息,然后另外一台设备也能够感知并作出相应的调整。那么,这时候你需要监听NSUbiquitousKeyValueStoreDidChangeExternallyNotification通知,它能够告诉你的App所保存的key-value有变更。

我们将上面的例子进行改造,将设置字符串改为设置一个背景颜色值,并且设定它的key为bg,然后通过监听NSUbiquitousKeyValueStoreDidChangeExternallyNotification通知来改变VC的视图背景颜色。

首先,我们在viewDidLoad中进行监听通知:

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    // 初始化keyValueStore
    self.keyValueStore = [NSUbiquitousKeyValueStore defaultStore];

    // 监听通知
    [[NSNotificationCenter defaultCenter] addObserverForName:NSUbiquitousKeyValueStoreDidChangeExternallyNotification object:self.keyValueStore queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) {
       
        if ([note.userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] containsObject:@"bg"])
        {
            long long bgColorValue = [self.keyValueStore longLongForKey:@"bg"];
            UIColor *bgColor = [UIColor colorWithRed:(bgColorValue & 0xff) / 0xff
                                               green:(bgColorValue >> 8 & 0xff) / 0xff
                                                blue:(bgColorValue >> 16 & 0xff) / 0xff
                                               alpha:1];
            self.view.backgroundColor = bgColor;
        }
        
    }];
}

在上面代码中的通知处理,我们先通过NSUbiquitousKeyValueStoreChangedKeysKey来判断变更的key中是否包含bg这个key,如果存在则表示背景颜色有变更,再从keyValueStore中取出颜色值并转换成UIColor对象并设置成视图的背景颜色。

同时,两个按钮的点击事件处理如下:

- (IBAction)setValueButtonClickedHandler:(id)sender
{
    [self.keyValueStore setLongLong:0x00ff00 forKey:@"bg"];
    [self.keyValueStore synchronize];
}

- (IBAction)getValueButtonClickedHandler:(id)sender
{
    long long bgColorValue = [self.keyValueStore longLongForKey:@"bg"];
    UIColor *bgColor = [UIColor colorWithRed:(bgColorValue & 0xff) / 0xff
                                       green:(bgColorValue >> 8 & 0xff) / 0xff
                                        blue:(bgColorValue >> 16 & 0xff) / 0xff
                                       alpha:1];
    self.view.backgroundColor = bgColor;
}

通过上面的改动,在测试的过程中如果其中一台设备点击了set value按钮,则另外一台设备就会收到通知,并且变更视图的背景颜色。

文档数据同步

有时候会有这样的需求,如果开发的app是一款阅读类工具或者是一款壁纸工具,那么,我们会希望用户所下载的书籍或者壁纸会同步到不同的设备上来方便用户的操作,不需要再次去找到这本书或者这张图片进行重新下载。那么,iCloud提供了这样的服务,允许你把一份文档上传到iCloud中,然后其他设备再同步app上传的文档。

要想使用文档数据同步服务,就需要配合UIDocument来完成这项工作,具体的处理流程我先简单的描述一下,这样可以快速帮助到大家来理解机制的运作。

  1. UIDocument创建一个子类,该类型主要对app的中的文档进行管理。
  2. 重写UIDocumentcontentsForType:error:loadFromContents:ofType:error:方法,让文档根据app内部机制来实现保存和读取。
  3. 通过UIDocumentsaveToURL:forSaveOperation:completionHandler:将文档保存到iCloud容器中。
  4. 其他设备可以NSMetadataQuery来获取iCloud容器的文档列表,并更新到本地。

这里要注意一个问题,因为涉及到网络同步等相关的一些列操作,并不仅仅是当前应用进程在访问文件,系统的进程和其他应用进程也会对相关文件进行处理,所以不能通过NSFileManager直接对iCloud容器中的文件进行操作

同时也要弄清楚一个概念,其实UIDocument并不是为iCloud而设,它同样可以管理本地的文档。唯一区别是如果你的文档要放到iCloud,那么传给UIDocument的文档路径必须是以iCloud容器地址开始的路径,这样才能实现文档的同步

那么,下面来举例介绍如何进行文档数据的同步,假设开发的app是一款壁纸应用,在应用壁纸时会下载图片,然后讲它保存到iCloud中。另外的设备就可以自动地同步下载的图片并应用该壁纸。

首先把界面给搭建起来,如下图所示:

壁纸界面演示

界面是使用UICollectionView搭建的,代码在这里就不贴上来了,主要关注设置图片背景的处理过程。

首先,继承UIDocument类型创建其一个子类BackgroundImage,并为BackgroundImage声明一个传入UIImage对象的构造方法以及重写contentsForType:error:loadFromContents:ofType:error:两个方法代码如下:

@interface BackgroundImage : UIDocument

// 图片对象
@property (nonatomic, strong, readonly) UIImage *image;

// 构造方法
- (instancetype)initWithFileURL:(NSURL *)url image:(UIImage *)image;

@end

@implementation BackgroundImage

- (instancetype)initWithFileURL:(NSURL *)url image:(UIImage *)image
{
    if (self = [super initWithFileURL:url])
    {
        _image = image;
    }
    return self;
}

- (id)contentsForType:(NSString *)typeName error:(NSError * _Nullable __autoreleasing *)outError
{
    return UIImageJPEGRepresentation(_image, 0.8);
}

- (BOOL)loadFromContents:(id)contents ofType:(NSString *)typeName error:(NSError * _Nullable __autoreleasing *)outError
{
    if ([contents isKindOfClass:[NSData class]])
    {
        _image = [UIImage imageWithData:contents];
    }
    
    return YES;
}

@end

注意:上述代码中的contentsForType:error:方法只允许返回NSData或者NSFileWrapper类型,不能直接把UIImage类型进行返回,否则会抛出错误提示:

The default implementation of -[UIDocument writeContents:toURL:forSaveOperation:originalContentsURL:error: only understands contents of type NSFileWrapper or NSData, not UIImage. You must override one of the write methods to support custom content types

不过可以重写writeContents:andAttributes:safelyToURL:forSaveOperation:error:方法来解决这个问题。

接下来,要取到iCloud容器的地址,在VC中新增一个方法用来获取容器路径:

- (NSURL *)icloudContainerBaseURL
{
    if ([NSFileManager defaultManager].ubiquityIdentityToken)
    {
        return [[NSFileManager defaultManager] URLForUbiquityContainerIdentifier:nil];
    }
    
    return nil;
}

上面代码先检测设备是否登录iCloud账号,NSFileManagerubiquityIdentityToken如果不为nil则表示已经登录账号。然后再通过URLForUbiquityContainerIdentifier:方法来获取容器的地址,参数可以传入容器的名称(即在项目配置时设置的容器,如:iCloud.cn.vimfung.app.iCloudDemo),传入nil则表示返回容器数组中的第一个容器。

如果URLForUbiquityContainerIdentifier:返回nil则表示iCloud服务不可用。

然后,就可以实现应用图片按钮的功能了,代码如下:

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    // 取得Cell对象
    BgCollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:reuseIdentifier forIndexPath:indexPath];
    // 让Cell现实图片
    cell.url = self.imageURLs[indexPath.row];
    
    // 应用图片按钮的点击事件回调
    __weak typeof(self) theController = self;
    [cell onApply:^(UIImage * _Nonnull image) {
       
        //同步文档
        NSURL *baseURL = [theController icloudContainerBaseURL];
        if (baseURL)
        {
            NSURL *bgURL = [baseURL URLByAppendingPathComponent:@"image.jpg"];
            BackgroundImage *bgImg = [[BackgroundImage alloc] initWithFileURL:bgURL image:image];
            [bgImg saveToURL:bgURL
            forSaveOperation:UIDocumentSaveForOverwriting
           completionHandler:^(BOOL success) {
               
               if (success)
               {
                   NSLog(@"同步成功!");
               }
               else
               {
                   NSLog(@"同步失败, 可以记录到本地等待下一次重新同步");
               }
               
            }];
        }
        else
        {
            NSLog(@"iCloud服务不可用,可根据需求进行相关处理");
        }
        
        // 将图片应用到CollectionView背景中。
        UIImageView *imageView = [[UIImageView alloc] initWithImage:image];
        imageView.contentMode = UIViewContentModeScaleAspectFill;
        collectionView.backgroundView = imageView;
        
    }];
    
    return cell;
}

上述代码中,主要看cellonApply:回调方法(该方法是一个自定义的方法,主要是用于将点击应用图片按钮的事件回调到VC中),整个处理流程是先获取容器地址,如果地址不为空,就创建一个BackgrounImage的文档对象,然后再调用对象的saveToURL:forSaveOperation:completionHandler:方法来对文档进行保存,这样就完成了文档上传的操作。

对于保存方法,我一直有个想不明白的地方就是初始化的时候已经传入了URL,为什么还需要传入一个NSURL对象来确定保存的路径,这真的让人摸不着方向。

不过现在我把这两个URL区分对待了,初始化传入的URL是用于打开文档时使用(UIDocument的open方法是不需要传URL的),而保存的方法中的URL就仅仅针对保存目标路径而言,如果路径与fileURL相同那就是更新文件,如果不同那就是拷贝文档了。

最后,如果想要在其他设备上同步背景图片,那么就需要在viewDidLoad里面同步处理,主要就是使用NSMetadataQuery来查找背景文件,如果背景文件存在加载为背景。具体代码如下:

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    // 进行文档同步
    NSURL *baseURL = [self icloudContainerBaseURL];
    if (baseURL)
    {
        __block NSMetadataQuery *query = [[NSMetadataQuery alloc] init];
        query.searchScopes = @[NSMetadataQueryUbiquitousDataScope];
        query.predicate = [NSPredicate predicateWithFormat:@"%K == 'image.jpg'", NSMetadataItemFSNameKey];
        
        NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
        [center addObserverForName:NSMetadataQueryDidFinishGatheringNotification object:query queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) {
            
            if (query.results.count > 0)
            {
                
                
                NSURL *fileURL = [(NSMetadataItem *)query.results.firstObject valueForAttribute:NSMetadataItemURLKey];
                
                //加载背景图片
                BackgroundImage *bgImage = [[BackgroundImage alloc] initWithFileURL:fileURL image:nil];
                [bgImage openWithCompletionHandler:^(BOOL success) {
                    
                    if (success)
                    {
                        //设置背景
                        UIImageView *imageView = [[UIImageView alloc] initWithImage:bgImage.image];
                        imageView.contentMode = UIViewContentModeScaleAspectFill;
                        theController.collectionView.backgroundView = imageView;
                    }
                    
                }];
            }
            
            query = nil;
            
        }];
        
        [query startQuery];
    }
}

这里要解释的是NSMetadataQuerysearchScopespredicate两个属性,通常用这两个属性可以完成简单的查找工作:

  • searchScopes属性主要用来告诉NSMetadataQuery一个有效的查询范围。它是一个数组类型,元素可以包含NSURL或者NSString类型,其中NSURL要求是一个目录路径,表示需要查找的目录,而NSString则必须为下表的取值。如果属性为nil则从所有目录中进行查找.
名称 说明
NSMetadataQueryUbiquitousDocumentsScope 指定该key表示在iCloud容器的Documents目录下进行文件查询
NSMetadataQueryUbiquitousDataScope 指定该key表示在iCloud容器根目录进行文件查询
NSMetadataQueryAccessibleUbiquitousExternalDocumentsScope 指定该key表示除应用程序容器目录外的所有可访问目录(如iCloud容器目录等)中进行文件查询
  • predicate主要用于匹配查找文件的条件,其中条件筛选可以与NSMetadataItem中的attribute keys相结合,上面代码就是使用NSMetadataItemFSNameKey来找到名称为image.jpg的背景图片。更多的属性可以参考下表:
名称 说明
NSMetadataItemFSNameKey 文件名称
NSMetadataItemDisplayNameKey 显示名称,不包含扩展名,跟文件名称可能不一样
NSMetadataItemURLKey 文件URL,以file://开头,为NSURL类型
NSMetadataItemPathKey 文件的绝对路径,为NSString类型
NSMetadataItemFSSizeKey 文件大小,单位为字节
NSMetadataItemFSCreationDateKey 文件创建时间,为NSDate类型
NSMetadataItemFSContentChangeDateKey 内容最后一次变更时间,为NSDate类型
NSMetadataItemContentTypeKey NSMetadataItem的内容类型,为UTI字符串
NSMetadataItemContentTypeTreeKey 这个官网并没有详细的说明,但从一些其他资料了解,这可能是表示NSMetadataItem的内容类型的从属链,返回的数组最后一个元素就是当前内容的类型,再往上就是这个类型的父类型,再往上就是父级的父级类型,直到第一个元素就是根类型(跟类继承类似)。例如:一张jpg图片返回的内容如下:["public.item", "public.data", "public.image", "public.jpeg", "public.content"]
NSMetadataItemIsUbiquitousKey 一个布尔值表示是否上传到iCloud中,类型为NSNumber
NSMetadataUbiquitousItemHasUnresolvedConflictsKey 一个布尔值表示当前文件与该文件其他版本发生冲突,如果该属性值为YES则需要先解决文件的冲突部分才能正常更新到iCloud上,其类型为NSNumber
NSMetadataUbiquitousItemIsDownloadedKey 一个布尔值表示文件是否已经下载到本地并且可用,其类型为NSNumber。iOS 7后使用NSMetadataUbiquitousItemDownloadingStatusKey来代替。
NSMetadataUbiquitousItemDownloadingStatusKey 使用NSString来表示文件的下载状态,下载状态取值如下:NSMetadataUbiquitousItemDownloadingStatusNotDownloaded 表示尚未下载、NSMetadataUbiquitousItemDownloadingStatusDownloaded 表示已下载、NSMetadataUbiquitousItemDownloadingStatusCurrent 表示是文件的最新版本
NSMetadataUbiquitousItemIsDownloadingKey 一个布尔值表示文件是否开始正在下载到本地,类型为NSNumber
NSMetadataUbiquitousItemIsUploadedKey 一个布尔值表示文件是否已经上传到iCloud中,类型为NSNumber
NSMetadataUbiquitousItemIsUploadingKey 一个布尔值表示文件是否正在上传到iCloud中,类型为NSNumber
NSMetadataUbiquitousItemPercentDownloadedKey 当前下载进度,范围为0.0 - 100.0,类型为NSNumber
NSMetadataUbiquitousItemPercentUploadedKey 当前上传进度,范围为0.0 - 100.0,类型为NSNumber
NSMetadataUbiquitousItemDownloadingErrorKey 表示下载过程中产生的错误信息描述,类型为NSError
NSMetadataUbiquitousItemUploadingErrorKey 表示上传过程中产生的错误信息描述,类型为NSError
NSMetadataUbiquitousItemDownloadRequestedKey 其包含一个布尔值,用于表示MetadataItem是在否已经开始下载。YES表示已经开始请求下载,NO表示正在等待下载。其类型为NSNumber
NSMetadataUbiquitousItemIsExternalDocumentKey 用于判断是否为应用容器外的文件,类型为NSNumber
NSMetadataUbiquitousItemContainerDisplayNameKey 文件所处iCloud容器的显示名称,类型为NSString
NSMetadataUbiquitousItemURLInLocalContainerKey 文件所处iCloud容器的本地URL,类型为NSURL
NSMetadataUbiquitousItemIsSharedKey 包含一个布尔值,YES表示为共享文件。
NSMetadataUbiquitousSharedItemCurrentUserRoleKey 返回共享文件的当前用户角色。如果返回nil则表示尚未共享。取之如下:NSMetadataUbiquitousSharedItemRoleOwner 表示共享文件的所有者、NSMetadataUbiquitousSharedItemRoleParticipant 表示共享文件的参与者
NSMetadataUbiquitousSharedItemCurrentUserPermissionsKey 返回共享文件的当前用户权限, 如果返回nil则表示尚未共享。取值如下:NSMetadataUbiquitousSharedItemPermissionsReadOnly 表示当前用户具有只读权限、NSMetadataUbiquitousSharedItemPermissionsReadWrite 表示用户具有读写权限
NSMetadataUbiquitousSharedItemOwnerNameComponentsKey 返回共享文件的所有者信息,其类型为NSPersonNameComponents。如果所有者为当前用户则返回nil
NSMetadataUbiquitousSharedItemMostRecentEditorNameComponentsKey 返回共享文件的最新编辑者信息,其类型为NSPersonNameComponents,如果最新编辑者为当前用户则返回nil,该属性只读。

接着再调用NSMetadataQuerystartQuery方法来进行查询操作。最后通过监听NSMetadataQueryDidFinishGatheringNotification通知来捕获查询完成事件来检测是否找到背景图片,如果存在图片则通过BackgroundImage来加载图片并将它作为UICollectionView的背景视图。

这里要注意的是查询操作只能在应用激活时调用并执行,因此如果应用退到后台,则需要使用stopQuery来停止查询,等待应用恢复后在重新调用startQuery来进行查询。

上面的代码只实现了在视图加载将背景同步到本地并显示,如果你想在应用运行时也能够监控到背景图片的变更,那么就需要把NSMetadataQuery保留起来,让他生命周期与应用生命周期一样,然后通过NSMetadataQueryDidUpdateNotification通知来捕获更新,下面我们来改写刚才的代码:

@interface iCloudDocumentViewController ()

//... 

/**
 查询
 */
@property (nonatomic, strong) NSMetadataQuery *query;


/**
 是否需要更新背景
 */
@property (nonatomic) BOOL needUpdateBg;

@end

@implementation iCloudDocumentViewController

- (void)viewDidLoad
{
    [super viewDidLoad];

    NSURL *baseURL = [self icloudContainerBaseURL];
    if (baseURL)
    {
        self.query = [[NSMetadataQuery alloc] init];
        self.query.searchScopes = @[NSMetadataQueryUbiquitousDataScope];
        self.query.predicate = [NSPredicate predicateWithFormat:@"%K == 'image.jpg'", NSMetadataItemFSNameKey];
        [self.query enableUpdates];
        
        __weak typeof(self) theController = self;
        NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
        [center addObserverForName:NSMetadataQueryDidUpdateNotification object:self.query queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) {
            
            if (theController.query.results.count > 0)
            {
                NSMetadataItem *item = theController.query.results.firstObject;
                NSString *status = [item valueForAttribute:NSMetadataUbiquitousItemDownloadingStatusKey];
                if ([status isEqualToString:NSMetadataUbiquitousItemDownloadingStatusDownloaded])
                {
                    theController.needUpdateBg = YES;
                }
                
                if (theController.needUpdateBg && [status isEqualToString:NSMetadataUbiquitousItemDownloadingStatusCurrent])
                {
                    theController.needUpdateBg = NO;
                    
                    //更新背景
                    NSURL *fileURL = [item valueForAttribute:NSMetadataItemURLKey];
                    BackgroundImage *bgImage = [[BackgroundImage alloc] initWithFileURL:fileURL image:nil];
                    [bgImage openWithCompletionHandler:^(BOOL success) {
                        
                        if (success)
                        {
                            //设置背景
                            UIImageView *imageView = [[UIImageView alloc] initWithImage:bgImage.image];
                            imageView.contentMode = UIViewContentModeScaleAspectFill;
                            theController.collectionView.backgroundView = imageView;
                        }
                        
                    }];
                }
            }
            
        }];
        
        [center addObserverForName:NSMetadataQueryDidFinishGatheringNotification object:self.query queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) {
            
            if (theController.query.results.count > 0)
            {
                NSMetadataItem *item = theController.query.results.firstObject;
                
                //更新背景
                NSURL *fileURL = [item valueForAttribute:NSMetadataItemURLKey];
                BackgroundImage *bgImage = [[BackgroundImage alloc] initWithFileURL:fileURL image:nil];
                [bgImage openWithCompletionHandler:^(BOOL success) {
                    
                    if (success)
                    {
                        //设置背景
                        UIImageView *imageView = [[UIImageView alloc] initWithImage:bgImage.image];
                        imageView.contentMode = UIViewContentModeScaleAspectFill;
                        theController.collectionView.backgroundView = imageView;
                    }
                    
                }];
            }
            
        }];
        
        [self.query startQuery];
    }
}

@end

这里把NSMetadataQuery作为VC的属性,主要是因为只有一个VC,所以这样做是没有问题的。然后增加了一个needUpdateBg属性,用于标识是否需要更新背景。从代码可以看到调用enableUpdates方法并且新增了一个NSMetadataQueryDidUpdateNotification通知的监听,这一步就是让文档有更新触发通知回调。

对于更新的回调,它只要iCloud中的文件有变更就会触发,所以这里就需要对NSMetadataItem是否已经下载到本地进行判断,只有完全更新到本地后才进行背景图的更新。所以回调中要比对NSMetadataUbiquitousItemDownloadingStatusKey,如果状态值为NSMetadataUbiquitousItemDownloadingStatusCurrent就表示本地的图片是最新的,才进行显示。

文档的同步更新就先说到这里吧,这部分的内容涉及比较多,后续我会深入研究这部分内容然后再给大家分享。

本地数据库(CoreData)同步

很多时候都会用到本地数据库来存储一些配置和缓存信息。对于一个电商App,在未登录应用账号时,添加到购物车的商品其实也可以使用本地数据库来存储。如果想要购物车的东西同步到其他设备上,那么就可以借助iCloud同步去实现。

对于本地数据库(SQLite)的操作,目前比较常用的有iOS原生的CoreData框架,另外就是第三方的FMDB。个人比较偏向CoreData,开发起来好处很多,一方面是实体关系都是可视化的,而且自动生成实体类型,不需要考虑一些SQL的编写,特别是处理复杂关系。另外一方面就是数据在后续更新,它提供了一套更新映射方案,不需要单独进行合并或者迁移处理。同时,CoreData跟其他系统库的结合更友好,iCloud就是其中一个。

那么,上面的需求我们就是采用CoreData去实现,下面搭建一个简单的演示界面:

演示界面

再新建一个模型文件Model.xcdatamodeld,如下图所示:

数据模型定义

PreOrder就是用于记录购物车中要购买的商品信息,gid为商品标识(一般由服务器下发),count为购买商品的数量。

然后在viewDidLoad中初始化CoreData。如:

@interface CoreDataViewController ()

@property (nonatomic, strong) NSManagedObjectModel *managedObjectModel;

@property (nonatomic, strong) NSPersistentStoreCoordinator *persistentStoreCoordinator;

@property (nonatomic, strong) NSPersistentStore *persistentStore;

@property (nonatomic, strong) NSManagedObjectContext *managedObjectContext;

/**
 商品列表
 */
@property (nonatomic, strong) NSArray<NSDictionary *> *goodsList;

@end

@implementation CoreDataViewController

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    //为演示需要,创建一个商品列表,正常情况这部分数据要从服务端下发
    self.goodsList = @[@{@"gid" : @0, @"name" : @"商品0"}, @{@"gid" : @1, @"name" : @"商品1"}, @{@"gid" : @2, @"name" : @"商品2"}, @{@"gid" : @3, @"name" : @"商品3"}, @{@"gid" : @4, @"name" : @"商品4"}];

    //初始化CoreData
    NSURL *baseURL = [[NSFileManager defaultManager] URLForUbiquityContainerIdentifier:nil];
    NSURL *storeURL = [baseURL URLByAppendingPathComponent:@"data.sqlite"];
    NSURL *modelURL = [[NSBundle mainBundle] URLForResource:@"Model" withExtension:@"momd"];
    self.managedObjectModel = [[NSManagedObjectModel alloc] initWithContentsOfURL:modelURL];
    self.persistentStoreCoordinator = [[NSPersistentStoreCoordinator alloc] initWithManagedObjectModel:self.managedObjectModel];
    
    // 要同步iCloud必须设置存储配置,并且包含NSPersistentStoreUbiquitousContentNameKey
    NSDictionary *storeOptions = @{NSPersistentStoreUbiquitousContentNameKey: @"CoreData"};
    self.persistentStore = [self.persistentStoreCoordinator addPersistentStoreWithType:NSSQLiteStoreType
                                                                         configuration:nil
                                                                                   URL:storeURL
                                                                               options:storeOptions
                                                                                 error:nil];

    self.managedObjectContext = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
    
    __weak typeof(self) theController = self;
    [self.managedObjectContext performBlockAndWait:^{
        [theController.managedObjectContext setPersistentStoreCoordinator:theController.persistentStoreCoordinator];
    }];
}

@end

上面代码有两个地方需要注意:

  1. 数据库的存储路径必须是iCloud上的地址,这跟文档同步一样,通过URLForUbiquityContainerIdentifier:方法先取得容器地址,再生成数据库的存储地址。如果是本地地址可能由于沙箱权限问题就会导致发生下面的错误:

CoreData: error: -addPersistentStoreWithType:SQLite configuration:(null) URL:file:///var/mobile/Containers/Data/Application/825F3D35-2FD7-41F5-BF7A-58B98E5E5540/data.sqlite options:{
NSPersistentStoreRebuildFromUbiquitousContentOption = 1;
NSPersistentStoreUbiquitousContentNameKey = CoreData1;
} ... returned error Error Domain=NSCocoaErrorDomain Code=513 "You don’t have permission to save the file “store” in the folder “A15BEA0D-4C18-4321-8D6C-5BFBBB0A1DAF”." UserInfo={NSFilePath=/var/mobile/Containers/Data/Application/825F3D35-2FD7-41F5-BF7A-58B98E5E5540/CoreDataUbiquitySupport/mobile~25F77E70-BFB3-475A-82E2-C84F65B59CA7/CoreData1/A15BEA0D-4C18-4321-8D6C-5BFBBB0A1DAF/store, NSUnderlyingError=0x28143e730 {Error Domain=NSPOSIXErrorDomain Code=1 "Operation not permitted"}} with userInfo dictionary {
NSFilePath = "/var/mobile/Containers/Data/Application/825F3D35-2FD7-41F5-BF7A-58B98E5E5540/CoreDataUbiquitySupport/mobile~25F77E70-BFB3-475A-82E2-C84F65B59CA7/CoreData1/A15BEA0D-4C18-4321-8D6C-5BFBBB0A1DAF/store";
NSUnderlyingError = "Error Domain=NSPOSIXErrorDomain Code=1 "Operation not permitted"";
}

  1. 需要在调用addPersistentStoreWithType:方法时传入一个包含NSPersistentStoreUbiquitousContentNameKeyoptions参数,该key的值是数据库存储在iCloud上的名称(名称自定义)。

接下来我们先了解一下关于CoreData同步的三个通知:

通知 说明
NSPersistentStoreCoordinatorStoresWillChangeNotification 与持久化存储变更相关(如:迁移合并数据库,变更存储位置等),在变更前会派发此通知。同时,该通知还会在iCloud账号变更和删除文档数据之前派发消息
NSPersistentStoreCoordinatorStoresDidChangeNotification NSPersistentStoreCoordinatorStoresWillChangeNotification类似,在持久化存储变更后进行通知派发,其包含userInfo信息,其中包含下面几个Key:NSAddedPersistentStoresKey 新增的持久化存储(NSArray)、NSRemovedPersistentStoresKey 移除的持久化存储(NSArray)、NSUUIDChangedPersistentStoresKey 变更的持久化存储(NSArray),包含新旧持久化存储信息,第一个元素为旧存储实例,第二个元素是新存储实例,当存在数据迁移时,数组还包含第三个元素,该元素是包含所有已迁移实例的新objectID数组
NSPersistentStoreDidImportUbiquitousContentChangesNotification 当iCloud中存储的数据发生变化时会向设备派发此通知,通知中包含了增删改的一些详细信息,我们不需要做过多的事情,只要调用NSManagedObjectContextmergeChangesFromContextDidSaveNotification的方法来合并变更内容即可。

在这里我们需要监听NSPersistentStoreCoordinatorStoresDidChangeNotificationNSPersistentStoreDidImportUbiquitousContentChangesNotification两个通知。前一个主要时在应用首次启动时同步线上数据库版本,后一个是在其他设备调整购物车数据时可以实时监听并合并数据进行UI上的更新显示。代码如下:

@interface CoreDataViewController ()

//...

/**
 购物车按钮
 */
@property (weak, nonatomic) IBOutlet UIButton *carButton;

/**
 购物车商品
 */
@property (nonatomic, strong) NSArray<PreOrder *> *preOrders;

@end

@implementation CoreDataViewController

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    //...

    NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
   
    [center addObserverForName:NSPersistentStoreCoordinatorStoresDidChangeNotification object:self.persistentStoreCoordinator queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) {
        
        [self.managedObjectContext performBlock:^{
            
            if ([self.managedObjectContext hasChanges])
            {
                // 有变更则保存
                [self.managedObjectContext save:nil];
            }
            
            // 刷新购物车
            [self updateShoppingCart];
            
        }];
    }];
    [center addObserverForName:NSPersistentStoreDidImportUbiquitousContentChangesNotification object:self.persistentStoreCoordinator queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) {
        
        [self.managedObjectContext performBlock:^{
            // 合并变更的数据
            [self.managedObjectContext mergeChangesFromContextDidSaveNotification:note];
            // 刷新购物车
            [self updateShoppingCart];
        }];
        
    }];
    
}

/**
 更新购物车
 */
- (void)updateShoppingCart
{
    NSFetchRequest *request = [[NSFetchRequest alloc] initWithEntityName:@"PreOrder"];
    self.preOrders = [self.managedObjectContext executeFetchRequest:request error:nil];
    
    //更新显示
    [self.carButton setTitle:[NSString stringWithFormat:@"购物车(%ld)", self.preOrders.count] forState:UIControlStateNormal];
    [self.carButton sizeToFit];
}

@end

最后,我们在为每个商品后面的购买按钮写上点击事件,如下:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"GoodsCell"];
    if (!cell)
    {
        cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"GoodsCell"];
        
        UIButton *btn = [UIButton buttonWithType:UIButtonTypeSystem];
        [btn setTitle:@"购买" forState:UIControlStateNormal];
        [btn sizeToFit];
        [btn addTarget:self
                action:@selector(buyButtonClickedHandler:)
      forControlEvents:UIControlEventTouchUpInside];
        
        cell.accessoryView = btn;
    }
    
    cell.textLabel.text = self.goodsList[indexPath.row][@"name"];
    cell.accessoryView.tag = indexPath.row;
    
    return cell;
}

// 购买按钮点击事件
- (void)buyButtonClickedHandler:(UIButton *)sender
{
    NSInteger index = sender.tag;
    
    int gid = [self.goodsList[index][@"gid"] intValue];
    NSFetchRequest *request = [[NSFetchRequest alloc] initWithEntityName:@"PreOrder"];
    request.predicate = [NSPredicate predicateWithFormat:@"gid = %d", gid];
    PreOrder *preOrder = [self.managedObjectContext executeFetchRequest:request error:nil].firstObject;
    if (!preOrder)
    {
        preOrder = [NSEntityDescription insertNewObjectForEntityForName:@"PreOrder" inManagedObjectContext:self.managedObjectContext];
        preOrder.gid = gid;
    }
    
    preOrder.count ++;
    [self.managedObjectContext save:nil];
    
    [self updateShoppingCart];
}

通过点击购买按钮,就会将商品写入数据库并保存,一旦保存成功将会同步到iCloud中,然后其他设备会得到相应的更新通知。

上面例子到这里算是把CoreData同步流程完整地演示了一遍。但是从iOS 10以后这种CoreData同步形式已经被苹果标注过时了,因为CloudKit已经可以取代这样的操作。接下来我继续跟大家一起探讨CloudKit的使用。

CloudKit使用

其实CloudKit并不是什么新的东西,在没有出现它之前,笔者接触过的Parse(该项目已经关停,目前代码已经开源)和国内仿Parse做的LeanCloud其实就是类似的产品,都是属于后端管理的产品,允许通过可视化的界面来建立数据实体以及实体间的联系,然后在客户端可以轻松地通过一系列的SDK接口来查询、编辑这些实体数据。同样,CloudKit也是这样的一种模式,我们可以先看一下CloudKit的管理后台截图:

CloudKit Dashboard

从这个界面可以看到CloudKit划分了开发(Development)和生产(Production)两个环境。开发产品的时候就需要在开发环境下进行,直到开发完成并进行App发布时,就可以将开发环境发布到生产环境中。CloudKit包含很多内容,在这里我会先针对文章主题,将常用的流程给大家进行演示。

还是使用CoreData中购物车的例子来进行演示说明。首先,要使用CloudKit必须要在Capabilities页签中勾选CloudKit一项,然后点击页签中的CloudKit dashboard按钮,可以快速地打开CloudKit的管理后台(上图的界面)。

在这里我们点击开发环境中的Data一栏来进入到数据管理界面。如图:

Development Data界面

上面的界面只关注Records和Record Types两个标签页。Records页主要展示实体数据的记录数据以及查询。左侧用来指定哪个数据库的哪个类型,然后筛选条件和排序规则是什么,点击Query Records就可以在右边界面显示查询到的记录内容。Record Types页则是用于管理所有记录的类型,这里的Record Type其实跟CoreData中的Entity一样,包含了实体的属性和关系。

这里需要介绍关于CloudKit中有三种不同的数据库的类型:

数据库 说明
Private Database 私有数据库,与每个iCloud用户关联,只用当前iCloud用户才能访问其私有数据库的数据,开发者无权限访问这些数据。在开发中可以通过CKContainer实例的privateCloudDatabase属性来操作私有数据库。
Shared Database 共享数据库,在用户登录后才能够访问,其用于与其他用户共享私有数据库的一条或多条记录。可以通过CKContainer实例的sharedCloudDatabase属性来操作共享数据库。
Public Database 公有数据库,与应用关联,存储的数据对所有用户可见。用户不需要登录也能够读取数据,但是写入数据则必须用户登录iCloud后才能进行。通过CKCOntainer实例的publicCloudDatabase属性来操作公有数据库。

购物车属于个人信息,不应该被其他用户查看,因此,我们这里使用私有数据库来操作数据,来保存购物车的商品信息。首先需要创建一个新的Record Type,点击Record Types标签页,如图:

Record Types界面

点击Create New Type来创建一个PreOrders的类型并为其创建gid和count字段,如图:

添加类型

点击右下角的Create Record Type按钮,确认创建类型。创建成功后会多出一些系统默认字段,如图:

创建类型完成

然后再返回Records标签页就能够发现新创建的PreOrders类型了。

注:Record Type的创建并不区分数据库,一旦对Record Type进行新增、修改或删除都会影响所有数据库中的对应类型。

记录类型创建好后就可以回到Xcode进行相关的编码处理。把CoreData的界面进行调整,新建一个CloudKitViewController视图控制器类型与UI进行关联,其代码如下:

@interface CloudKitViewController ()

/**
 购物车按钮
 */
@property (weak, nonatomic) IBOutlet UIBarButtonItem *carButtonItem;

/**
 商品列表
 */
@property (nonatomic, strong) NSArray<NSDictionary *> *goodsList;

/**
 购物车商品列表
 */
@property (nonatomic, strong) NSArray<CKRecord *> *preOrders;

@end

@implementation CloudKitViewController

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    //...

    // 更新购物车信息
    [self updateShoppingCart];
}

/**
 更新购物车
 */
- (void)updateShoppingCart
{
    CKContainer *container = [CKContainer defaultContainer];
    [container accountStatusWithCompletionHandler:^(CKAccountStatus accountStatus, NSError * _Nullable error) {
        
        //只有账户登录后才能访问私有数据库
        if (accountStatus == CKAccountStatusAvailable)
        {
            CKDatabase *db = container.privateCloudDatabase;
            
            CKQuery *query = [[CKQuery alloc] initWithRecordType:@"PreOrders" predicate:[NSPredicate predicateWithValue:YES]];
            [db performQuery:query inZoneWithID:nil completionHandler:^(NSArray<CKRecord *> * _Nullable results, NSError * _Nullable error) {
                
                if (!error)
                {
                    self.preOrders = results;
                    
                    dispatch_async(dispatch_get_main_queue(), ^{
                        
                        //更新显示
                        self.carButtonItem.title = [NSString stringWithFormat:@"购物车(%ld)", self.preOrders.count];
                        
                    });
                    
                }
                
            }];
        }
        
    }];
}

@end

上面代码中主要是updateShoppingCart这个方法,其获取了用户的私有数据库,并且将所有PreOrders查询出来。方法中首先对用户的iCloud的登录状态进行了判断,因为CKContainerprivateCloudDatabase属性即使在用户未登录状态下也会正常返回,但一旦对其进行操作就会报错,所以需要使用accountStatusWithCompletionHandler:方法进行账号状态判断。

这里的代码也相对简单,主要使用了CKQuery对象进行查询的操作。构建查询需要指定RecordType和查询条件。在这里为PreOrders,然后查询条件设置为所有PreOrder记录都符合条件。然后通过CKDatabasepreformQuery:inZoneWithID:comletionHandler:来执行查询操作。

注:如果查询的Record Type没有索引,则查询就会报错:
<CKError 0x6000028f6640: "Invalid Arguments" (12/2015); server message = "Type is not marked indexable"; uuid = C22FF9D7-C619-4EA8-95A1-EF702078927F; container ID = "iCloud.cn.vimfung.app.iCloudDemo">

如果建立的索引并没有应用到查询条件时,则会默认使用Record Type的recordName属性作为索引,如果没有为该字段建立Queryable索引,则会导致如下报错:
<CKError 0x600003623360: "Invalid Arguments" (12/2015); server message = "Field 'recordName' is not marked queryable"; uuid = 9E63EC88-9B7A-45FF-A7E9-B08471F6AED5; container ID = "iCloud.cn.vimfung.app.iCloudDemo">

之前没有为PreOrders建立,所以查询会报错。我们回到管理后台的Indexs标签页,选择PreOrders类型,在右边界面点击Add Index按钮将recordNamegid添加为Queryable索引。如图:

添加索引

点击右下角Save Record Type按钮保存,然后再回到项目中测试就能够正常查询了。

接下来,就是改写商品的购买按钮,代码如下:

- (void)buyButtonClickedHandler:(UIButton *)sender
{
    CKContainer *container = [CKContainer defaultContainer];
    [container accountStatusWithCompletionHandler:^(CKAccountStatus accountStatus, NSError * _Nullable error) {
        
        //只有账户登录后才能访问私有数据库
        if (accountStatus == CKAccountStatusAvailable)
        {
            NSInteger index = sender.tag;
            
            int gid = [self.goodsList[index][@"gid"] intValue];
            
            //先查询是否存在该商品
            CKQuery *query = [[CKQuery alloc] initWithRecordType:@"PreOrders" predicate:[NSPredicate predicateWithFormat:@"gid = %d", gid]];
            CKDatabase *db = container.privateCloudDatabase;
            [db performQuery:query inZoneWithID:nil completionHandler:^(NSArray<CKRecord *> * _Nullable results, NSError * _Nullable error) {
                
                if (!error)
                {
                    CKRecord *preOrder = nil;
                    if (results.count > 0)
                    {
                        preOrder = results.firstObject;
                        NSNumber *countNum = [preOrder objectForKey:@"count"];
                        [preOrder setObject:@(countNum.intValue + 1) forKey:@"count"];
                    }
                    else
                    {
                        preOrder = [[CKRecord alloc] initWithRecordType:@"PreOrders"];
                        [preOrder setObject:@(1) forKey:@"count"];
                        [preOrder setObject:@(gid) forKey:@"gid"];
                    }
                    
                    [db saveRecord:preOrder completionHandler:^(CKRecord * _Nullable record, NSError * _Nullable error) {
                        
                        if (!error)
                        {
                            NSLog(@"save suc!");
                            //刷新购物车
                            [self updateShoppingCart];
                        }
                        
                    }];
                }
                
            }];
        }
    }];
}

上面代码流程先取到购买商品ID,然后通过商品ID来查找对应的PreOrder记录,如果存在记录则将count属性增加1,如果没有对应记录则新建记录,并将设置gidcount,然后通过SKDatabasesaveRecord:completionHandler:来对记录进行保存。保存成功后更新UI。

整个例子到这里已经改写完成,通过上面的调整发现,其实CloudKit不算复杂,但是与上一章的CoreData相比,该例子没有实现实时监听数据变更的操作。就目前来看,笔者对这块的了解并不多,只是简单地讲述了一些基础的东西,同样,往后会继续研究这块的内容,在适当的时间再给大家分享。

那么,所有的内容到这里就告一段落了,感谢各位同学看到最后,如果文章里面存在什么问题欢迎指出来,如果有什么疑问也可以在这里提,最后再次感谢大家支持~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容