数据持久化方案解析(三) —— 基于NSCoding的持久化存储(一)

版本记录

版本号 时间
V1.0 2018.10.25 星期四

前言

数据的持久化存储是移动端不可避免的一个问题,很多时候的业务逻辑都需要我们进行本地化存储解决和完成,我们可以采用很多持久化存储方案,比如说plist文件(属性列表)、preference(偏好设置)、NSKeyedArchiver(归档)、SQLite 3CoreData,这里基本上我们都用过。这几种方案各有优缺点,其中,CoreData是苹果极力推荐我们使用的一种方式,我已经将它分离出去一个专题进行说明讲解。这个专题主要就是针对另外几种数据持久化存储方案而设立。
1. 数据持久化方案解析(一) —— 一个简单的基于SQLite持久化方案示例(一)
2. 数据持久化方案解析(二) —— 一个简单的基于SQLite持久化方案示例(二)

开始

首先看一下写作环境

Swift 4.2, iOS 12, Xcode 10

在iOS中有很多方法可以将数据保存到磁盘 - 原始文件API,Property List SerializationCore DataRealm等第三方解决方案,当然还有NSCoding

对于需要大量数据的应用程序,Core DataRealm通常是最佳选择。对于轻量数据要求,NSCoding通常更好,因为它更容易采用和使用。

Swift 4还提供了另一种轻量级选择:Codable。这与NSCoding非常相似,但存在一些差异。

如果您需要一种简单的方法将数据保存到磁盘,NSCodingCodable都是不错的选择!但是,它们都不支持查询或创建复杂的对象图。如果您需要这些,则应使用Core DataRealm或其他数据库解决方案。

NSCoding的主要缺点是需要你依赖Foundation。 Codable不依赖于任何框架,但您必须在Swift中编写模型才能使用它。

Codable还提供了NSCoding没有的几个丰富功能。例如,您可以使用它轻松地将模型序列化为JSON。

许多FoundationUIKit类都使用NSCoding,因为它自iOS 2.0开始就存在 - 是的,十几年!大约一年前,在Swift 4中添加了Codable,由于许多Apple类都是用Objective-C编写的,因此很可能很快就不会更新以支持Codable

无论您选择在应用程序中使用NSCoding还是Codable,最好了解两者是如何工作的。在本教程中,您将学习有关NSCoding的所有知识!

你将开展一个名为“Scary Creatures”的示例项目。这个应用程序可以让你保存生物的照片并评估它们的可怕程度。 但是,此时它不会保留数据,因此如果重新启动应用程序,则添加的所有生物都将丢失。 因此,该应用程序还不是很有用......

在本教程中,您将使用NSCodingFileManager保存并加载每个生物的数据。 之后,您将熟悉NSSecureCoding以及它可以做些什么来改善您的应用程序中的数据加载。

首先,在Xcode中打开入门项目并浏览项目中的文件:

您将使用的主要文件是:

  • ScaryCreatureData.swift包含有关生物,名称和等级的简单数据。
  • ScaryCreatureDoc.swift包含有关该生物的完整信息,包括数据,缩略图图像和该生物的完整图像。
  • MasterViewController.swift显示所有存储生物的列表。
  • DetailViewController.swift显示所选生物的细节,并允许您对其进行评分。

构建并运行以了解应用程序的工作方式。


Implementing NSCoding - 实现NSCoding

NSCoding是一种协议,您可以在数据类上实现该协议,以支持将数据编码和解码到数据缓冲区中,然后数据缓冲区可以保留在磁盘上。

实现NSCoding实际上非常容易 - 这就是为什么你会发现它有用的原因。

首先,打开ScaryCreatureData.swift并将NSCoding添加到类声明中,如下所示:

class ScaryCreatureData: NSObject, NSCoding

然后将这两个方法添加到类中:

func encode(with aCoder: NSCoder) {
  //add code here
}

required convenience init?(coder aDecoder: NSCoder) {
  //add code here
  self.init(title: "", rating: 0)
}

您需要实现这两个方法以使类符合NSCoding。 第一种方法编码对象。 第二个解码数据以实例化新对象。

简而言之,encode(with:)是编码器,init(coder:)是解码器。

注意:此处关键字convenience的存在不是NSCoding的要求。 它就在那里,因为你在初始化程序中调用了一个指定的初始化程序init(title:rating :)。 如果您尝试删除它,Xcode会给您一个错误。 如果您选择自动修复它,编辑器将再次添加相同的关键字。

在实现这两个方法之前,为了代码组织,在类的开头添加以下枚举:

enum Keys: String {
  case title = "Title"
  case rating = "Rating"
}

虽然看似非常微不足道,但它是值得的。 Codable键使用String数据类型。 如果没有编译器捕获你的错误,字符串很容易拼错。 通过使用枚举,编译器将确保您始终使用一致的键名称,Xcode将在您键入时为您提供代码完成。

现在,你准备好了有趣的部分。 添加以下内容进行encode(with:)

aCoder.encode(title, forKey: Keys.title.rawValue)
aCoder.encode(rating, forKey: Keys.rating.rawValue)

encode(_:forKey :)将作为第一个参数提供的值写入并将其绑定到键。 提供的值必须是符合NSCoding协议的类型。

将以下代码添加到init?(coder:)的开头:

let title = aDecoder.decodeObject(forKey: Keys.title.rawValue) as! String
let rating = aDecoder.decodeFloat(forKey: Keys.rating.rawValue)

这恰恰相反。 您从指定键提供的NSCoder对象中读取值。 由于您要保存两个值,因此您希望再次读取相同的两个值以正常恢复应用程序。

您现在需要使用解码的内容实际构建生物的数据。 替换此行:

self.init(title: "", rating: 0)

使用下面这行

self.init(title: title, rating: rating)

OK! 使用这几行代码,ScaryCreatureData类符合NSCoding


Loading and Saving to Disk - 加载和存储到磁盘

接下来,您需要添加代码来访问磁盘,读取和写入存储的生物数据。

为了提高性能 - 在构建应用程序时始终牢记这一点 - 您不会立即加载所有数据。

1. Adding the Initializer - 添加初始化程序

打开ScaryCreatureDoc.swift并将以下内容添加到类的末尾:

var docPath: URL?
  
init(docPath: URL) {
  super.init()
  self.docPath = docPath    
}

docPath将存储ScaryCreatureData信息在磁盘上的位置。 这里的诀窍是你应该在第一次访问它时将信息加载到内存中,而不是初始化对象。

但是,如果要创建一个全新的生物,则此路径将为nil,因为尚未为该文档创建文件。 您将在旁边添加Bookkeeping代码,以确保在创建新生物时设置此代码。

2. Adding Bookkeeping Code - 添加簿记代码

将此枚举添加到ScaryCreatureDoc的开头,紧跟在大括号后面:

enum Keys: String {
  case dataFile = "Data.plist"
  case thumbImageFile = "thumbImage.png"
  case fullImageFile = "fullImage.png"
}

接下来,将thumbtergetter替换为:

get {
  if _thumbImage != nil { return _thumbImage }
  if docPath == nil { return nil }

  let thumbImageURL = docPath!.appendingPathComponent(Keys.thumbImageFile.rawValue)
  guard let imageData = try? Data(contentsOf: thumbImageURL) else { return nil }
  _thumbImage = UIImage(data: imageData)
  return _thumbImage
}

接下来,使用以下命令替换fullImagegetter

get {
  if _fullImage != nil { return _fullImage }
  if docPath == nil { return nil }
  
  let fullImageURL = docPath!.appendingPathComponent(Keys.fullImageFile.rawValue)
  guard let imageData = try? Data(contentsOf: fullImageURL) else { return nil }
  _fullImage = UIImage(data: imageData)
  return _fullImage
}

由于您要将每个生物保存在自己的文件夹中,因此您将创建一个帮助程序类,以提供存储该生物文档的下一个可用文件夹。

创建一个名为ScaryCreatureDatabase.swift的新Swift文件,并在文件末尾添加以下内容:

class ScaryCreatureDatabase: NSObject {
  class func nextScaryCreatureDocPath() -> URL? {
    return nil
  }
}

你会在一段时间内为这个新类添加更多内容。 现在,返回ScaryCreatureDoc.swift并将以下内容添加到类的末尾:

func createDataPath() throws {
  guard docPath == nil else { return }

  docPath = ScaryCreatureDatabase.nextScaryCreatureDocPath()
  try FileManager.default.createDirectory(at: docPath!,
                                          withIntermediateDirectories: true,
                                          attributes: nil)
}

createDataPath()正如其名称所说的那样。 它使用数据库中的下一个可用路径填充docPath属性,并且仅当docPathnil时才创建该文件夹。 如果不是,这意味着它已经正确发生。

3. Saving Data - 保存数据

接下来,您将添加逻辑以将ScaryCreateData保存到磁盘。 在createDataPath()的定义之后添加此代码:

func saveData() {
  // 1
  guard let data = data else { return }
    
  // 2
  do {
    try createDataPath()
  } catch {
    print("Couldn't create save folder. " + error.localizedDescription)
    return
  }
    
  // 3
  let dataURL = docPath!.appendingPathComponent(Keys.dataFile.rawValue)
    
  // 4
  let codedData = try! NSKeyedArchiver.archivedData(withRootObject: data, 
                                                    requiringSecureCoding: false)
    
  // 5
  do {
    try codedData.write(to: dataURL)
  } catch {
    print("Couldn't write to save file: " + error.localizedDescription)
  }
}

这是这样做的:

  • 1) 确保data中存在某些内容,否则只需return,因为没有任何内容可以保存。
  • 2) 调用createDataPath()以准备保存创建的文件夹中的数据。
  • 3) 构建要写入信息的文件的路径。
  • 4) 编码dataScaryCreatureData的一个实例,您之前使其符合NSCoding。 您现在将requiresSecureCoding设置为false,但稍后您将进行此操作。
  • 5) 将编码数据写入在步骤3中创建的文件路径。

接下来,将此行添加到init(title:rating:thumbImage:fullImage:)的末尾:

saveData()

这可确保在创建新实例后保存数据。

很好! 这样可以保存数据。 好吧,该应用程序仍然不会实际保存图像,但您将在本教程后面添加它。

4. Loading Data - 加载数据中

如上所述,我们的想法是在您第一次访问它时将信息加载到内存中,而不是在初始化对象时加载。 如果你有很长的生物列表,这可以改善应用程序的加载时间。

注意:ScaryCreatureDoc中的属性都是通过带有gettersetter的私有属性访问的。 初学者项目本身并没有从中受益,但它已经添加,以便您继续执行后续步骤。

打开ScaryCreatureDoc.swift并使用以下内容替换data的getter:

get {
  // 1
  if _data != nil { return _data }
  
  // 2
  let dataURL = docPath!.appendingPathComponent(Keys.dataFile.rawValue)
  guard let codedData = try? Data(contentsOf: dataURL) else { return nil }
  
  // 3
  _data = try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(codedData) as?
      ScaryCreatureData
  
  return _data
}

这就是加载先前通过调用saveData()创建的已保存ScaryCreatureData所需的全部内容。 这是它的作用:

  • 1) 如果数据已经加载到内存中,只需返回它。
  • 2) 否则,请将已保存文件的内容作为一种Data读取。
  • 3) Unarchive先前编码的ScaryCreatureData对象的内容并开始使用它们。

您现在可以从磁盘保存和加载数据! 但是,在应用程序准备发布之前还有更多内容。

5. Deleting Data - 删除数据

应用程序还应允许用户删除生物,也许留下来太可怕了。

saveData()的定义之后立即添加以下代码:

func deleteDoc() {
  if let docPath = docPath {
    do {
      try FileManager.default.removeItem(at: docPath)
    }catch {
      print("Error Deleting Folder. " + error.localizedDescription)
    }
  }
}

此方法只删除包含文件的整个文件夹,其中包含生物数据。


Completing ScaryCreatureDatabase - 完成ScaryCreatureDatabase

您之前创建的ScaryCreatureDatabase类有两个作业。 第一个,你已经写了一个空方法,是提供下一个可用的路径来创建一个新的生物文件夹。 它的第二项工作是加载你之前保存的所有存储的生物。

在实现这两个功能之一之前,您需要一个帮助方法来返回应用程序存储生物的位置 - 数据库实际位于何处。

打开ScaryCreatureDatabase.swift,并在开始类花括号后面添加此代码:

static let privateDocsDir: URL = {
  // 1
  let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
  
  // 2
  let documentsDirectoryURL = paths.first!.appendingPathComponent("PrivateDocuments")
  
  // 3
  do {
    try FileManager.default.createDirectory(at: documentsDirectoryURL,
                                            withIntermediateDirectories: true,
                                            attributes: nil)
  } catch {
    print("Couldn't create directory")
  }
  return documentsDirectoryURL
}()

这是一个非常方便的变量,它存储数据库文件夹路径的计算值,您在此处将其命名为“PrivateDocuments”。以下是它的工作原理:

  • 1) 获取应用程序的Documents文件夹,这是所有应用程序都具有的标准文件夹。
  • 2) 构建指向包含所有内容的数据库文件夹的路径。
  • 3) 如果文件夹不存在则创建该文件夹并返回路径。

您现在已准备好实现上述两个函数。 您将从保存的文档加载数据库开始。 将以下代码添加到类的底部:

class func loadScaryCreatureDocs() -> [ScaryCreatureDoc] {
  // 1
  guard let files = try? FileManager.default.contentsOfDirectory(
    at: privateDocsDir,
    includingPropertiesForKeys: nil,
    options: .skipsHiddenFiles) else { return [] }
  
  return files
    .filter { $0.pathExtension == "scarycreature" } // 2
    .map { ScaryCreatureDoc(docPath: $0) } // 3
}

这会加载存储在磁盘上的所有.scarycreature文件,并返回一个ScaryCreatureDoc项目数组。 在这里,你这样做:

  • 1) 获取数据库文件夹的所有内容。
  • 2) 过滤列表仅包含以.scarycreature结尾的项目。
  • 3) 从筛选的列表中加载数据库并将其返回。

接下来,您要正确返回用于存储新文档的下一个可用路径。 用这个替换nextScaryCreatureDocPath()的实现:

// 1
guard let files = try? FileManager.default.contentsOfDirectory(
  at: privateDocsDir,
  includingPropertiesForKeys: nil,
  options: .skipsHiddenFiles) else { return nil }

var maxNumber = 0

// 2
files.forEach {
  if $0.pathExtension == "scarycreature" {
    let fileName = $0.deletingPathExtension().lastPathComponent
    maxNumber = max(maxNumber, Int(fileName) ?? 0)
  }
}

// 3
return privateDocsDir.appendingPathComponent(
  "\(maxNumber + 1).scarycreature",
  isDirectory: true)

与之前的方法类似,您获取数据库的所有内容,过滤它们,附加到privateDocsDir并返回它。

跟踪磁盘上所有项目的简单方法是按编号命名文件夹;通过查找名为最高编号的文件夹,您可以轻松地提供下一个可用路径。

注意:使用数字只是为基于文档的数据库命名和跟踪文件夹的一种方法。 只要每个文件夹都有一个唯一的名称,您就可以选择其他方式,这样就不会意外地用新的文件替换现有的项目。

好的 - 你差不多完成了! 是时候尝试了。


Trying It Out! - 试一试!

在运行应用程序之前,在privateDocsDir的类属性定义的末尾return之前添加此行:

print(documentsDirectoryURL.absoluteString)

当应用程序在模拟器中运行时,这将帮助您准确了解计算机上包含文档的文件夹的位置。

现在,运行应用程序。 从控制台复制值,但跳过“file://”部分。 路径应以“/ Users”开头,以“/ PrivateDocuments”结尾。

打开Finder应用程序。 从菜单导航,Go ▸ Go to Folder并在对话框中粘贴路径:

Paste the path you copied from the console here

打开文件夹时,其内容应如下所示:

您在此处看到的项目是由MasterViewController.loadCreatures()创建的,这是在初学者项目中为您实现的。 每次运行应用程序时,它都会在磁盘上添加更多文档......这实际上并不正确! 发生这种情况是因为在加载应用程序时您没有从磁盘读取数据库的内容。 你马上解决这个问题,但首先,你需要实现更多的东西。

如果用户在表视图上触发删除,则还需要从数据库中删除该生物。 在同一个文件中,用以下代码替换tableView(_:commit:forRowAt :)的实现:

if editingStyle == .delete {
  let creatureToDelete = creatures.remove(at: indexPath.row)
  creatureToDelete.deleteDoc()
  tableView.deleteRows(at: [indexPath], with: .fade)
}

最后一件事你需要考虑:你完成了添加和删除功能,但是编辑呢? 别担心......它就像实现删除一样简单。

打开DetailViewController.swift并在rateViewRatingDidChange(rateView:newRating :)titleFieldTextChanged(_ :)的末尾添加以下行:

detailItem?.saveData()

这只是告诉ScaryCreatureDoc对象在用户界面中更改其信息时保存自己。


Saving and Loading Images - 保存和加载图像

该生物应用程序剩下的最后一件事是保存和加载图像。 您不会将它们保存在列表文件本身中;将它们作为普通图像文件保存在其他存储数据旁边会更方便,所以现在你要编写代码。

ScaryCreatureDoc.swift中,在类的末尾添加以下代码:

func saveImages() {
  // 1
  if _fullImage == nil || _thumbImage == nil { return }
  
  // 2
  do {
    try createDataPath()
  } catch {
    print("Couldn't create save Folder. " + error.localizedDescription)
    return
  }
  
  // 3
  let thumbImageURL = docPath!.appendingPathComponent(Keys.thumbImageFile.rawValue)
  let fullImageURL = docPath!.appendingPathComponent(Keys.fullImageFile.rawValue)
  
  // 4
  let thumbImageData = _thumbImage!.pngData()
  let fullImageData = _fullImage!.pngData()
  
  // 5
  try! thumbImageData!.write(to: thumbImageURL)
  try! fullImageData!.write(to: fullImageURL)
}

这有点类似于之前在saveData()中编写的内容:

  • 1) 确保存储图像;否则,没有必要继续执行。
  • 2) 如果需要,创建数据路径。
  • 3) 构建指向磁盘上每个文件的路径。
  • 4) 将每个图像转换为PNG数据表示,以便您在磁盘上写入。
  • 5) 将生成的数据写入磁盘的各自路径中。

项目中有两点要调用saveImages()

第一个是初始化程序init(title:rating:thumbImage:fullImage:)。 打开ScaryCreatureDoc.swift,并在此初始化程序结束时,在saveData()之后,添加以下行:

saveImages()

第二点是在DetailViewController.swift里面的imagePickerController(_:didFinishPickingMediaWithInfo:)。 您将找到一个调度闭包,您可以在其中更新detailItem中的图像。 将此行添加到闭包的末尾:

self.detailItem?.saveImages()

现在,您可以保存,更新和删除生物。 该应用程序已准备好保存您将来可能遇到的所有恐怖和非恐怖的生物。

如果你现在要构建并运行并从磁盘恢复可怕的生物,你会发现有些人拥有图像而其他人没有,就像这样:

使用Xcode调试控制台中打印的路径,查找并删除PrivateDocuments文件夹。 现在构建并运行一次。 你会看到他们的图像的初始生物:

当你保存你的生物时,你无法看到你已经保存了什么。 打开MasterViewController.swift并将loadCreatures()的实现替换为:

creatures = ScaryCreatureDatabase.loadScaryCreatureDocs()

这会从磁盘加载生物而不是使用预先填充的列表。

构建并再次运行。 尝试更改标题和评级。 当您返回主屏幕时,应用程序会将更改保存到磁盘。


Implementing NSSecureCoding - 实现NSSecureCoding

在iOS 6中,Apple推出了一些基于NSCoding的新功能。 您可能已经注意到,您解码了存档中的值,以将它们存储在如下行的变量中:

let title = aDecoder.decodeObject(forKey: Keys.title.rawValue) as! String

读取值时,它已经加载到内存中,然后将其转换为您应该知道的数据类型。 如果出现问题并且先前写入的对象的类型无法转换为所需的数据类型,则该对象将完全加载到内存中,然后转换尝试将失败。

诀窍是行动的顺序;虽然应用程序根本不会使用该对象,但该对象已经完全加载到内存中,然后在失败的转换后释放。

NSSecureCoding提供了一种加载数据的方法,同时在解码时验证其类,而不是之后。 最好的部分是它非常容易实现。

首先,在ScaryCreatureData.swift中,使类实现协议NSSecureCoding,以便类声明如下所示:

class ScaryCreatureData: NSObject, NSCoding, NSSecureCoding

然后在类的末尾添加以下代码:

static var supportsSecureCoding: Bool {
  return true
}

这就是您遵守NSSecureCoding所需的全部内容,但您尚未从中获益。

用以下代码替换encode(with :)实现:

aCoder.encode(title as NSString, forKey: Keys.title.rawValue)
aCoder.encode(NSNumber(value: rating), forKey: Keys.rating.rawValue)

现在,用这个替换init?(coder :)的实现:

let title = aDecoder.decodeObject(of: NSString.self, forKey: Keys.title.rawValue) 
  as String? ?? ""
let rating = aDecoder.decodeObject(of: NSNumber.self, forKey: Keys.rating.rawValue)
self.init(title: title, rating: rating?.floatValue ?? 0)

如果查看新的初始化代码,您会注意到这个decodeObject(of:forKey :)decodeObject(forKey :)不同,因为它所采用的第一个参数是一个类。

不幸的是,使用NSSecureCoding要求你在Objective-C中使用string和float对应对象;这就是使用NSStringNSNumber的原因,然后将值转换回Swift StringFloat

最后一步是告诉NSKeyedArchiver使用安全编码。 在ScaryCreatureDoc.swift中,更改saveData()中的以下行:

let codedData = try! NSKeyedArchiver.archivedData(withRootObject: data, 
                                                  requiringSecureCoding: false)

替换成下面

let codedData = try! NSKeyedArchiver.archivedData(withRootObject: data, 
                                                  requiringSecureCoding: true)

在这里,您只需将true传递给requiresSecureCoding而不是false。 这告诉NSKeyedArchiver在归档对象及其后代时强制执行NSSecureCoding

注意:以前使用NSSecureCoding编写的文件现在不兼容。 您需要删除以前保存的所有数据或从模拟器中卸载应用程序。 在实际场景中,您必须迁移旧数据。

后记

本篇主要讲述了基于NSCoding的持久化存储,感兴趣的给个赞或者关注~~~

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

推荐阅读更多精彩内容