FileProvider框架详细解析 (二) —— 实现File Provider extension(一)

版本记录

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

前言

今天翻阅苹果的API文档,发现多了一个框架就是FileProvider,看了下才看见是iOS11.0新添加的框架,这里我们就一起来看一下框架FileProvider。感兴趣的看下面几篇文章。
1. FileProvider框架详细解析 (一) —— 基本概览(一)

开始

首先看下写作环境

Swift 5, iOS 12, Xcode 10

在本教程中,您将了解File Provider框架以及如何实现自己的File Provider extension以公开应用程序自己的内容。

iOS 11中首次引入了File Provider extension,可通过iOS Files应用程序访问您的应用程序管理的内容。 此外,其他应用程序可以使用UIDocumentBrowserViewControllerUIDocumentPickerViewController类来访问您的应用程序的数据。

File Provider extension的主要任务是:

  • 创建表示远程内容的文件占位符。
  • 拦截从主机应用程序读取,以便文档可以下载或更新。
  • 在更新文档后发出通知,以便更改可以上载到远程服务器。
  • 枚举存储的文档和目录。
  • 对文档执行操作,例如重命名,移动或删除。

您将使用Heroku Button配置托管文件的服务器。 服务器设置完成后,您将配置扩展以枚举服务器的内容。

打开Favart-Starter文件夹并在Xcode中打开Favart.xcodeproj。 确保您已选择Favart scheme,然后构建并运行该应用程序,您将看到以下内容:

该应用程序提供了一个基本视图,用于教育用户如何启用File Provider extension,因为您实际上不会在应用程序本身内执行任何操作。每次在本教程中构建和运行应用程序时,您都将返回主屏幕并打开Files应用程序以访问您的扩展程序。

注意:如果要在实际设备上运行示例项目,除了为两个目标设置开发团队之外,还必须在Configuration文件夹中编辑Favart.xcconfig。将bundle identifier更新为唯一值。

示例项目将此值用于两个目标中的PRODUCT_BUNDLE_IDENTIFIER构建设置,以及Provider.entitlements中的App Group标识符和Info.plist中的关联NSExtensionFileProviderDocumentGroup值。如果不在项目中一致地更新值,则可能会看到模糊且难以调试的错误。使用自定义构建设置是保持平稳运行的好方法。

示例项目包括您将用于File Provider extension的基本组件:

  • NetworkClient.swift包含用于与Heroku服务器通信的网络客户端。
  • FileProviderExtension.swift本身具有File Provider extension
  • FileProviderEnumerator.swift包含枚举器,用于列出目录的内容。
  • Models是一个包含完成扩展所需模型的组。

Setting Up the Back End With Heroku

首先,您需要自己的后端服务器实例。 幸运的是,使用Heroku Button很容易。 单击下面的按钮以访问Heroku dashboard

在您注册免费帐户或登录Heroku后,您将最终进入类似以下内容的页面。

在此页面上,您可以选择为应用程序命名。 您可以选择一个名称,如果您将该字段留空,Heroku将为您生成一个名称。 由于无需担心其他配置,请单击Deploy app按钮,片刻之后,您的后端将启动并运行。

Heroku完成部署应用程序后,单击底部的View按钮。这将带您到承载后端实例的URL。在应用程序的根目录下,您应该看到一条JSON消息,是Hello world!

最后,您需要复制Heroku实例的URL。您只需要域部分,它应如下所示:{app-name} .herokuapp.com

starter项目中,打开Provider / NetworkClient.swift。在文件的顶部,您应该看到一条警告:Add your Heroku URL here。删除警告并使用您的URL替换components.host占位符字符串。

这样就完成了服务器配置。接下来,您将定义File Provider所依赖的模型。


Defining an NSFileProviderItem

首先,File Provider需要一个符合NSFileProviderItem的模型。此模型将提供有关文件提供程序管理的文件的信息。初始化项目包含FileProviderItem.swift中的FileProviderItem,您将使用它,但在符合协议之前需要做一些工作。

虽然该协议有27个属性,但只需要4个。可选属性为File Provider框架提供有关每个文件的更多详细信息,并启用其他功能。在本教程中,您将专注于所需的属性:itemIdentifierparentItemIdentifierfilenametypeIdentifier

itemIdentifier为模型提供唯一可识别的密钥。File Provider使用parentIdentifier来跟踪其在扩展的层次结构中的位置。

filenameFiles/em> app<. finally>typeIdentifier是项目的uniform type identifierUTI中展示的item名称。

FileProviderItem符合NSFileProviderItem之前,它需要一种方法来处理来自后端的数据。 MediaItem定义了一个从后端返回的简单模型。 MediaItemReference不是直接在FileProviderItem中使用这个模型,而是处理扩展的一些额外逻辑以弥补差距。

您将在本教程中使用MediaItemReference有两个原因:

  • 1) Heroku上的后端非常简单。它无法提供NSFileProviderItem所需的所有信息,因此您需要在其他地方获取它。
  • 2) File Provider extension也很简单。更完整的File Provider extension需要使用诸如Core Data之类的东西在本地持久保存后端返回的信息,以便在扩展的生命周期中稍后引用它。

为了将教程的重点放在文件File Provider extension本身上,您将使用MediaItemReference通过将四个必需属性的数据嵌入到URL对象中来作弊。然后,您将对该URL进行base64编码,并将其编码为NSFileProviderItemIdentifier。您不需要自己保留任何内容,因为NSFileProviderExtension会为您处理它。

要开始构建模型,请打开Provider / MediaItemReference.swift并在MediaItemReference中添加以下内容:

// 1
private let urlRepresentation: URL

// 2
private var isRoot: Bool {
  return urlRepresentation.path == "/"
}

// 3
private init(urlRepresentation: URL) {
  self.urlRepresentation = urlRepresentation
}

// 4
init(path: String, filename: String) {
  let isDirectory = filename.components(separatedBy: ".").count == 1
  let pathComponents = path.components(separatedBy: "/").filter {
    !$0.isEmpty
  } + [filename]
  
  var absolutePath = "/" + pathComponents.joined(separator: "/")
  if isDirectory {
    absolutePath.append("/")
  }
  absolutePath = absolutePath.addingPercentEncoding(
    withAllowedCharacters: .urlPathAllowed
  ) ?? absolutePath
  
  self.init(urlRepresentation: URL(string: "itemReference://\(absolutePath)")!)
}

这是你做的:

  • 1) 对于本教程,URL将表示NSFileProviderItem所需的大部分信息。
  • 2) 此计算属性确定当前项是否是文件系统的根。
  • 3) 您将此初始化程序设为私有以防止从模型外部使用。
  • 4) 从后端读取数据时,您将使用此初始化程序。 您假设如果文件名不包含句点,则它必须是目录,因为初始化程序无法推断其类型。

在添加最终初始化程序之前,请使用以下内容替换文件顶部的import语句:

import FileProvider

接下来,在前面的代码下面添加以下初始化器:

init?(itemIdentifier: NSFileProviderItemIdentifier) {
  guard itemIdentifier != .rootContainer else {
    self.init(urlRepresentation: URL(string: "itemReference:///")!)
    return
  }
  
  guard 
    let data = Data(base64Encoded: itemIdentifier.rawValue),
    let url = URL(dataRepresentation: data, relativeTo: nil) 
    else {
      return nil
  }
  
  self.init(urlRepresentation: url)
}

大部分扩展都将使用此初始化程序。 记下schemeitemReference://。 您可以单独处理根容器标识符,以确保正确设置其URL路径。

对于其他项,通过将标识符的原始值转换为base64编码的数据来检索URL表示。 URL中的信息来自首先枚举实例的网络请求。

现在初始化器已经不在了,是时候为这个模型添加一些属性了。 首先,在文件顶部添加以下导入:

import MobileCoreServices

这提供了对文件类型的访问。 接下来,在结构体的末尾添加以下内容:

// 1
var itemIdentifier: NSFileProviderItemIdentifier {
  if isRoot {
    return .rootContainer
  } else {
    return NSFileProviderItemIdentifier(
      rawValue: urlRepresentation.dataRepresentation.base64EncodedString()
    )
  }
}

var isDirectory: Bool {
  return urlRepresentation.hasDirectoryPath
}

var path: String {
  return urlRepresentation.path
}

var containingDirectory: String {
  return urlRepresentation.deletingLastPathComponent().path
}

var filename: String {
  return urlRepresentation.lastPathComponent
}

// 2
var typeIdentifier: String {
  guard !isDirectory else {
    return kUTTypeFolder as String
  }
  
  let pathExtension = urlRepresentation.pathExtension
  let unmanaged = UTTypeCreatePreferredIdentifierForTag(
    kUTTagClassFilenameExtension,
    pathExtension as CFString,
    nil
  )
  let retained = unmanaged?.takeRetainedValue()
  
  return (retained as String?) ?? ""
}

// 3
var parentReference: MediaItemReference? {
  guard !isRoot else {
    return nil
  }
  return MediaItemReference(
    urlRepresentation: urlRepresentation.deletingLastPathComponent()
  )
}

记住:

  • 1) itemIdentifier对于FileProvider管理的每个项目必须是唯一的。 如果项引用是根,则它使用NSFileProviderItemIdentifier.rootContainer。 如果没有,它会从引用的URL创建一个标识符。
  • 2) 这里它根据URL的路径扩展创建一个标识符。 奇怪的UTTypeCreatePreferredIdentifierForTag是一个C函数,它返回给定输入的UTI类型。
  • 3) 在目录结构中工作时,引用父级是很有用的。 这表示包含当前引用的文件夹。 它是可选的,因为根引用没有父引用。

您在此处添加了一些其他属性,这些属性不需要太多解释,但在创建NSFileProviderItem时非常有用。 这样,参考模型就完成了。 是时候在FileProviderItemhook所有内容。

打开FileProviderItem.swift并在文件顶部添加以下内容:

import FileProvider

然后,在类实现之外将以下内容添加到文件的底部:

// MARK: - NSFileProviderItem

extension FileProviderItem: NSFileProviderItem {
  // 1
  var itemIdentifier: NSFileProviderItemIdentifier {
    return reference.itemIdentifier
  }
  
  var parentItemIdentifier: NSFileProviderItemIdentifier {
    return reference.parentReference?.itemIdentifier ?? itemIdentifier
  }
  
  var filename: String {
    return reference.filename
  }
  
  var typeIdentifier: String {
    return reference.typeIdentifier
  }
  
  // 2
  var capabilities: NSFileProviderItemCapabilities {
    if reference.isDirectory {
      return [.allowsReading, .allowsContentEnumerating]
    } else {
      return [.allowsReading]
    }
  }
  
  // 3
  var documentSize: NSNumber? {
    return nil
  }
}

FileProviderItem现在符合NSFileProviderItem并实现所有必需的属性。 仔细阅读代码:

  • 1) 大多数必需属性只是映射您之前添加到MediaItemReference的逻辑。
  • 2) NSFileProviderItemCapabilities指示可以对文档浏览器中的项目执行哪些操作 - 例如读取和删除。 对于这个应用程序,您只需要允许读取和枚举目录。 在实际用例中,您可能会使用.allowsAll功能,因为用户希望所有操作都能正常工作。
  • 3) 本教程不会使用文档大小,但它包含在内以防止NSFileProviderManager.writePlaceholder(at:withMetadata :)中的崩溃。 这可能是框架的一个bug,幸运的是,一个典型的应用程序的File Extension无论如何都会提供documentSize

这就是模型。 NSFileProviderItem具有更多属性,但是您已实现的内容足以满足本教程的需要。


Enumerating Documents

现在该模型已经到位,是时候将它投入使用了。 要向用户显示模型定义的项目,您需要告知系统应用程序的内容。

NSFileProviderEnumerator定义系统与应用程序内容之间的关系。 稍后,您将通过提供表示当前上下文的NSFileProviderItemIdentifier来查看系统如何请求枚举器。 如果用户正在查看扩展的根目录,系统将提供.rootContainer标识符。 当用户在目录内导航时,系统会传入模型定义的该项的标识符。

首先,您将构建启动项目中提供的枚举器。 打开Provider / FileProviderEnumerator.swift并在path下添加以下内容:

private var currentTask: URLSessionTask?

此属性将存储对当前网络任务的引用。 这提供了取消请求的能力。

接下来,使用以下内容替换enumerateItems(for:startingAt :)的内容:

let task = NetworkClient.shared.getMediaItems(atPath: path) { results, error in
  guard let results = results else {
    let error = error ?? FileProviderError.noContentFromServer
    observer.finishEnumeratingWithError(error)
    return
  }

  let items = results.map { mediaItem -> FileProviderItem in
    let ref = MediaItemReference(path: self.path, filename: mediaItem.name)
    return FileProviderItem(reference: ref)
  }

  observer.didEnumerate(items)
  observer.finishEnumerating(upTo: nil)
}

currentTask = task

这里,提供的网络客户端代码获取指定路径上的所有项目。 成功请求后,枚举器的观察者通过调用didEnumerate然后使用finishEnumerating(upTo :)来返回新数据,以指示批次项目的结束。 它通过调用finishEnumeratingWithError通知枚举器的观察者是否从请求中发生错误。

注意:生产应用程序可能使用分页来获取数据。 它将使用NSFileProviderPage方法参数来执行此操作。 在这种情况下,应用程序将使用整数作为页面索引,然后将其序列化并存储在NSFileProviderPage结构中。

完成枚举器的最后一步是将以下内容添加到invalidate()

currentTask?.cancel()
currentTask = nil

如果需要,这将取消当前的网络请求。 了解用户设备上的资源使用总是一个好主意,例如网络或位置访问。

完成该方法后,就可以使用此枚举器访问存储在后端的数据。 应用程序逻辑的其余部分将进入FileProviderExtension类。

打开Provider / FileProviderExtension.swift并用以下内容替换item(for :)的内容:

guard let reference = MediaItemReference(itemIdentifier: identifier) else {
  throw NSError.fileProviderErrorForNonExistentItem(withIdentifier: identifier)
}
return FileProviderItem(reference: reference)

系统提供传递给此方法的标识符,并返回该标识符的FileProviderItemguard语句确保标识符创建有效的MediaItemReference

接下来,使用以下代码替换urlForItem(withPersistentIdentifier :)persistentIdentifierForItem(at :)

// 1
override func urlForItem(withPersistentIdentifier
  identifier: NSFileProviderItemIdentifier) -> URL? {
  guard let item = try? item(for: identifier) else {
    return nil
  }
  
  return NSFileProviderManager.default.documentStorageURL
    .appendingPathComponent(identifier.rawValue, isDirectory: true)
    .appendingPathComponent(item.filename)
}

// 2
override func persistentIdentifierForItem(at url: URL) -> NSFileProviderItemIdentifier? {
  let identifier = url.deletingLastPathComponent().lastPathComponent
  return NSFileProviderItemIdentifier(identifier)
}

继续这个:

  • 1) 验证项目以确保给定标识符解析为扩展模型的实例。 然后返回一个文件URL,指定将项目存储在文件管理器的文档存储目录中的位置。
  • 2) urlForItem(withPersistentIdentifier :)返回的每个URL都需要映射回最初设置为表示的NSFileProviderItemIdentifier。 在该方法中,您以<documentStorageURL> / <itemIdentifier> / <filename>格式构建了URL,因此在这里您将采用倒数第二个路径组件作为项标识符。

有两种方法需要您引用引用远程文件的文件占位符URL。 首先,您将创建一个帮助方法来创建此占位符。 将以下内容添加到providePlaceholder(at :)

// 1
guard 
  let identifier = persistentIdentifierForItem(at: url),
  let reference = MediaItemReference(itemIdentifier: identifier) 
  else {
    throw FileProviderError.unableToFindMetadataForPlaceholder
}
 
// 2
try fileManager.createDirectory(
  at: url.deletingLastPathComponent(),
  withIntermediateDirectories: true,
  attributes: nil
)

// 3  
let placeholderURL = NSFileProviderManager.placeholderURL(for: url)
let item = FileProviderItem(reference: reference)
  
// 4
try NSFileProviderManager.writePlaceholder(
  at: placeholderURL,
  withMetadata: item
)

下面就是在做的事情:

  • 1) 首先,您从提供的URL创建标识符和引用。 如果失败,则抛出错误。
  • 2) 创建占位符时,必须确保封闭目录存在,否则您将遇到问题。 因此,使用NSFileManager来执行此操作。
  • 3) 传递给此方法的url用于显示图像,而不是占位符。 因此,您使用placeholderURL(for :)创建占位符URL,并获取此占位符将表示的NSFileProviderItem
  • 4) 将占位符项写入文件系统。

接下来,使用以下内容替换providePlaceholder(at:completionHandler :)的内容:

do {
  try providePlaceholder(at: url)
  completionHandler(nil)
} catch {
  completionHandler(error)
}

当需要占位符URL时,File Provider将调用providePlaceholder(at:completionHandler :)。 在其中,您尝试使用上面构建的帮助程序创建占位符,如果出现错误,则将其传递给completionHandler。 成功之后,您无需传递任何内容 - File Provider只需要您编写占位符URL,就像在providePlaceholder(at :)中一样。

当用户导航目录时,File Provider将调用enumerator(for:)以请求给定标识符的FileProviderEnumerator。 用以下内容替换此存根方法的内容:

if containerItemIdentifier == .rootContainer {
  return FileProviderEnumerator(path: "/")
}

guard 
  let ref = MediaItemReference(itemIdentifier: containerItemIdentifier),
  ref.isDirectory 
  else {
    throw FileProviderError.notAContainer
}

return FileProviderEnumerator(path: ref.path)

此方法确保提供的标识符的项目是目录。 如果标识符是根项,则仍然创建枚举器,因为根是有效目录。

建立并运行。 应用启动后,切换到Files应用并启用应用的扩展程序。 点击Browse选项卡栏项两次以导航到Files的根目录。 选择More Locations并切换Provider,这是扩展名称。

注意:如果找不到More Locations下列出的Provider,请点击右上角的Edit按钮,以确保列表中显示已禁用的扩展名。

您现在有一个有效的File Provider extension! 缺少一些重要的东西,但接下来你会添加它们。


Providing Thumbnails

由于此应用程序应显示后端的图像,因此显示图像的缩略图非常重要。 有一种覆盖方法可以处理扩展的缩略图生成。

enumerator(for:)以下添加:

// MARK: - Thumbnails
  
override func fetchThumbnails(
  for itemIdentifiers: [NSFileProviderItemIdentifier],
  requestedSize size: CGSize,
  perThumbnailCompletionHandler: 
    @escaping (NSFileProviderItemIdentifier, Data?, Error?) -> Void,
  completionHandler: @escaping (Error?) -> Void) 
    -> Progress {
  // 1
  let progress = Progress(totalUnitCount: Int64(itemIdentifiers.count))

  for itemIdentifier in itemIdentifiers {
    // 2
    let itemCompletion: (Data?, Error?) -> Void = { data, error in
      perThumbnailCompletionHandler(itemIdentifier, data, error)

      if progress.isFinished {
        DispatchQueue.main.async {
          completionHandler(nil)
        }
      }
    }

    guard 
      let reference = MediaItemReference(itemIdentifier: itemIdentifier),
      !reference.isDirectory 
      else {
        progress.completedUnitCount += 1

        let error = NSError.fileProviderErrorForNonExistentItem(
          withIdentifier: itemIdentifier
        )
        itemCompletion(nil, error)
        continue
    }

    let name = reference.filename
    let path = reference.containingDirectory

    // 3
    let task = NetworkClient.shared
      .downloadMediaItem(named: name, at: path) { url, error in
        guard 
          let url = url,
          let data = try? Data(contentsOf: url, options: .alwaysMapped) 
          else {
            itemCompletion(nil, error)
            return
        }
        itemCompletion(data, nil)
    }

    // 4
    progress.addChild(task.progress, withPendingUnitCount: 1)
  }

  return progress
}

虽然这种方法非常冗长,但其逻辑很简单:

  • 1) 此方法返回一个Progress对象,该对象跟踪每个缩略图请求的状态。
  • 2) 它为每个itemIdentifier定义一个完成块。 该块将负责调用此方法所需的每个项块以及最后调用最后一个块。
  • 3) 缩略图文件使用启动器项目附带的NetworkClient从服务器下载到临时文件。 下载完成后,完成处理程序将下载的数据data传递给itemCompletion闭包。
  • 4) 每个下载任务都作为依赖项添加到父进程对象。

注意:在处理较大的数据集时,为每个占位符发出单独的网络请求可能需要一些时间。 因此,如果可能,您的后端集成应提供在单个请求中批量下载图像的方法。

建立并运行。 在Files中打开扩展名,您应该看到加载缩略图。


Viewing Items

现在,当您选择一个项目时,该应用会显示一个没有完整图像的空白视图:

到目前为止,您只实现了预览缩略图的显示 - 现在是时候添加查看完整内容的功能了!

与缩略图生成一样,查看项目内容只需要一种方法,即startProvidingItem(at:completionHandler :)。 将以下内容添加到FileProviderExtension类的底部:

// MARK: - Providing Items

override func startProvidingItem(
  at url: URL, 
  completionHandler: @escaping ((_ error: Error?) -> Void)) {
  // 1
  guard !fileManager.fileExists(atPath: url.path) else {
    completionHandler(nil)
    return
  }

  // 2
  guard 
    let identifier = persistentIdentifierForItem(at: url),
    let reference = MediaItemReference(itemIdentifier: identifier) 
    else {
      completionHandler(FileProviderError.unableToFindMetadataForItem)
      return
  }

  // 3
  let name = reference.filename
  let path = reference.containingDirectory
  NetworkClient.shared
    .downloadMediaItem(named: name, at: path, isPreview: false) { fileURL, error in
    // 4
    guard let fileURL = fileURL else {
      return completionHandler(error)
    }

    // 5
    do {
      try self.fileManager.moveItem(at: fileURL, to: url)
      completionHandler(nil)
    } catch {
      completionHandler(error)
    }
  }
}

这是代码的作用:

  • 1) 检查指定URL中是否已存在某个项目,以防止再次请求相同的数据。 在实际案例使用中,您应该检查修改日期和文件版本,以确保您拥有最新数据。 但是,在本教程中没有必要这样做,因为它不支持版本控制。
  • 2) 获取关联URL的MediaItemReference,以便您知道需要从后端获取哪个文件。
  • 3) 从引用中提取名称和路径以从后端请求文件内容。
  • 4) 如果下载文件时出错,则会失败。
  • 5) 将文件从其临时下载目录移动到扩展指定的文档存储URL。

建立并运行。 打开扩展程序后,选择一个项目以查看完整版本。

当您打开更多文件时,扩展程序将需要处理删除下载的文件。File Provider extension内置了此功能。

您必须覆盖stopProvidingItem(at :)以清理文件并为其提供新的占位符。 在FileProviderExtension类的底部添加以下内容:

override func stopProvidingItem(at url: URL) {
  try? fileManager.removeItem(at: url)
  try? providePlaceholder(at: url)
}

这将删除该项,并调用providePlaceholder(at :)以生成新的占位符。

这样就完成了File Provider的最基本功能。 文件枚举,缩略图预览和查看文件内容是此扩展的基本组件。

恭喜, File Provider完整且功能齐全!

了解可以在Apple’s Documentation about File Providers中为File Provider实施的更多操作。 您还可以使用其他扩展程序将自定义UI添加到File Provider,您可以在here阅读更多相关信息。

后记

本篇主要讲述了实现File Provider extension,感兴趣的给个赞或者关注~~~

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

推荐阅读更多精彩内容