iOS apprentice中文版 - Chapter 34:Networking

Chapter 34:Networking

现在准备工作已经完成,你终于可以做些很棒的事情:在应用程序中添加网络,这样你就可以从iTunes商店下载实际数据了!

为了让子公司更容易找到产品,苹果推出了一项可以查询iTunes商店的web服务。你不会成为StoreSearch的会员,但是你可以使用免费的web服务来执行搜索。

在本章中,您将学习以下内容:

  1. 查询iTunes web服务:web服务介绍和查询苹果iTunes商店web服务的详细信息。

  2. 发送HTTP请求:如何创建合适的URL来查询web服务,以及如何向服务器发送请求。

  3. 解析JSON:如何理解从服务器发送的JSON信息,并将其转换为具有可在应用程序中使用的属性的对象。

  4. 处理JSON结果

  5. 搜索结果排序:探索不同的方法来排序搜索结果的字母,以便编写最简洁和紧凑的代码。


1. 查询iTunes web服务

那么什么是web服务呢?您的应用程序(也称为“客户机”)将使用HTTP协议通过网络向iTunes商店(即“服务器”)发送一条消息。
由于iPhone可以连接到不同类型的网络——Wi-Fi或LTE、3G或GPRS等蜂窝网络——该应用程序必须“说出”各种网络协议,才能与互联网上的其他电脑通信。


幸运的是,你不用担心这些,因为iPhone固件会处理这个复杂的过程。您只需要知道您正在使用HTTP。

HTTP是web浏览器在访问web站点时使用的相同协议。事实上,您可以使用web浏览器使用iTunes web服务。这是了解web服务如何工作的好方法。

这个技巧并不适用于所有的web服务—有些服务需要POST请求而不是GET请求,如果您不知道这意味着什么,现在不要担心—但是通常情况下,仅使用web浏览器就可以达到相当的效果。
打开你最喜欢的网络浏览器——我用的是Safari浏览器——打开下面的网址:
http://itunes.apple.com/search?term=metallica
浏览器会下载一个文件。如果您在文本编辑器中打开该文件,它应该包含如下内容:

{
"resultCount":50,
"results": [
{"wrapperType":"track", "kind":"song", "artistId":3996865, "collectionId":579372950, "trackId":579373079, "artistName":"Metallica", "collectionName":"Metallica", "trackName":"Enter Sandman", "collectionCensoredName":"Metallica", "trackCensoredName":"Enter Sandman", "artistViewUrl":"https://itunes.apple.com/us/artist/metallica/id3996865?uo=4", "collectionViewUrl":"https://itunes.apple.com/us/album/enter-sandman/id579372950?i=579373079&uo=4", "trackViewUrl":"https://itunes.apple.com/us/album/enter-sandman/id579372950?i=579373079&uo=4", "previewUrl":"http://a38.phobos.apple.com/us/r30/Music7/v4/bd/fd/e4/bdfde4e4-5407-9bb0-e632-edbf079bed21/mzaf_907706799096684396.plus.aac.p.m4a", "artworkUrl30":"http://is1.mzstatic.com/image/thumb/Music/v4/0b/9c/d2/0b9cd2e7-6e76-8912-0357-14780cc2616a/source/30x30bb.jpg", "artworkUrl60":"http://is1.mzstatic.com/image/thumb/Music/v4/0b/9c/d2/0b9cd2e7-6e76-8912-0357-14780cc2616a/source/60x60bb.jpg", "artworkUrl100":"http://is1.mzstatic.com/image/thumb/Music/v4/0b/9c/d2/0b9cd2e7-6e76-8912-0357-14780cc2616a/source/100x100bb.jpg", "collectionPrice":9.99, "trackPrice":1.29, "releaseDate":"1991-07-29T07:00:00Z", "collectionExplicitness":"notExplicit", "trackExplicitness":"notExplicit", "discCount":1, "discNumber":1, "trackCount":12, "trackNumber":1, "trackTimeMillis":331560, "country":"USA", "currency":"USD", "primaryGenreName":"Metal", "isStreamable":true},. . .

这些是iTunes网络服务提供给你的搜索结果。数据采用JSON格式,JSON代表JavaScript Object Notation。
JSON通常用于在服务器和客户端(即应用程序)之间来回发送结构化数据。您可能听说过的另一种数据格式是XML,但它正在被JSON快速取代。
您可以使用多种工具使JSON输出对普通人更具可读性。我安装了一个Quick Look插件,可以在一个彩色视图中呈现JSON文件(www.sagtau.com/quicklookjson.html)。

您确实需要先将服务器的输出保存到一个扩展名为.json的文件中,然后按下空格键从Finder打开它:


注意:您可以找到Safari(以及大多数其他浏览器)的扩展,它们可以在浏览器中直接美化JSON。github.com/rfletcher/safari-json-formatter就是一个很好的例子。
Mac应用程序商店中也有专用的工具,例如Visual JSON,它允许您直接在服务器上执行请求,并以结构化和可读的格式显示输出。
一个很棒的在线工具是 codebeautify.org/jsonviewer。

浏览一下JSON文本。您将看到服务器返回了一系列items,其中一些是歌曲,其他的是有声读物或音乐视频。
每个项目都有一堆与之相关的数据,比如艺人的名字——“Metallica”,这是您搜索的——还有曲目名称、流派、价格、发行日期等等。
您将在SearchResult类中存储这些字段,以便在屏幕上显示它们。

你从iTunes商店得到的结果可能和我的不一样。默认情况下,搜索最多返回50个条目,由于商店中有超过50个匹配“metallica”的条目,每次搜索都可能返回50个不同的结果集。
还要注意,其中一些字段,如artistViewUrl和artworkUrl100以及previewUrl是链接/ url。继续在浏览器中复制粘贴这些url,看看会发生什么。

artstviewurl将打开艺术家的iTunes预览页面,artworkUrl100加载缩略图,预览url将打开长达30秒的音频预览。

这是服务器告诉您附加资源的方式。图像等等并没有直接嵌入到搜索结果中,但是会给您一个URL,允许您单独下载每个条目。试试JSON数据中的其他url,看看它们是怎么做的!

回到最初的HTTP请求。您让web浏览器转到以下URL:

http://itunes.apple.com/search?term=the+search+term

您还可以添加其他参数,使搜索更加具体。例如:

http://itunes.apple.com/search?term=metallica&entity=song

现在搜索结果将不包含任何音乐视频或播客,只包含歌曲。
如果搜索项中有空格,应该用+号替换,如:

http://itunes.apple.com/search?term=pokemon+go&entity=software

搜索所有与《PokemanGo》有关的应用程序——你可能听说过其中一些。
这个特定查询的JSON结果中的字段与以前略有不同。没有previewUrls,但是每个条目都有几个screenshotUrls。不同类型的产品——歌曲、电影、软件——返回不同类型的数据。

就是这样。使用搜索参数构造一个到itunes.apple.com的URL,然后使用该URL发出HTTP请求。服务器会发送一些JSON的官样文章到应用程序,你必须把它转换成SearchResult对象,放到表格视图中。让我们开始吧!

同步网络 = 不好

在你开始之前,我应该指出,在你的应用程序中,有一种不好的网络方式,也有一种好的方式。

不好的方法是在应用程序的主线程上执行HTTP请求——这种编程方式很简单,但它会阻塞用户界面,使应用程序在联网时无法响应。因为它会阻塞应用程序的其余部分,所以这被称为同步网络(Synchronous networking)。

不幸的是,许多程序员坚持用错误的方式在他们的应用程序中建立网络,这使得应用程序运行缓慢,容易崩溃。

我将首先演示简单但不太好的方法,只是告诉您如何不这样做。认识到同步网络的后果很重要,这样您就可以在自己的应用程序中避免同步网络。

在我让你相信这种方法的邪恶之后,我将向你展示如何正确地做这件事——它只需要对代码做一个小的修改,但可能需要你在思考这些问题时做一个大的改变。

异步网络(Asynchronous networking)——正确的类型,加上一个“a”——使您的应用程序响应更快,但也带来了您需要额外处理的复杂性。


2.发送HTTP请求

为了查询iTunes Store web服务,您必须做的第一件事就是向iTunes服务器发送HTTP请求。这包括几个步骤,比如创建一个带有正确搜索参数的URL,向服务器发送请求,获得响应等等。

为请求创建URL

➤给searchviewcontroller添加一个新方法。

// MARK:- Helper Methods
func iTunesURL(searchText: String) -> URL {
  let urlString = String(format: 
      "https://itunes.apple.com/search?term=%@", searchText)
  let url = URL(string: urlString)
  return url!
}

这首先通过将搜索文本放在“term=”参数后面来构建URL字符串,然后将该字符串转换为URL对象。
因为URL(string:)是一个可失败的初始化器,所以它返回一个optional。您使用url!强制打开它并返回一个实际的URL对象。

HTTPS和HTTP
先前你使用http://,但在这里你使用https://。不同之处在于HTTPS是HTTP的安全加密版本。它可以保护你的用户不被窃听。底层协议是相同的,但是您发送或接收的任何字节在它们进入网络之前都是加密的。
从iOS 9开始,苹果建议应用程序应该始终使用HTTPS。事实上,即使您指定了一个不受保护的http:// URL, iOS仍然会尝试使用HTTPS连接。如果服务器没有配置为使用HTTPS,那么网络连接将会失败。
你可以要求在你的info.plist文件中免除这种行为,但一般不建议这样做。稍后,您将了解如何做到这一点,因为艺术图像托管在不支持HTTPS的服务器上。

➤将searchBarSearchButtonClicked(_:)更改为:

func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
  if !searchBar.text!.isEmpty {
    searchBar.resignFirstResponder()

    hasSearched = true
    searchResults = []

    let url = iTunesURL(searchText: searchBar.text!)
    print("URL: '\(url)'")

    tableView.reloadData()
  }
}

您已经删除了创建伪SearchResult项的代码,取而代之的是调用新的iTunesURL(searchText:)方法。出于测试目的,您需要记录这个方法返回的URL对象。

这个逻辑位于一个if语句中,所以除非用户实际在搜索栏中输入文本,否则这一切都不会发生——在iTunes商店中搜索“nothing”没有多大意义。

注意:不要被一行中的所有感叹号搞混,
如果searchBar.text ! .isEmpty
第一个是“logical not”操作符,因为只有当文本不为空时,才希望进入if语句。第二个感叹号用于强制展开searchBar的值。text是可选的,它永远不会是nil,所以它是可选的有点傻,但你会怎么做?

运行应用程序,输入一些单个单词的搜索文本,例如“metallica”,或者你最喜欢的金属乐队之一,然后按下搜索按钮。
Xcode现在应该在它的Debug窗格中显示这个:

URL: 'https://itunes.apple.com/search?term=metallica'

看起来不错。

➤现在在搜索框中输入一个或多个空格的搜索词,比如“pokemon go”。
哎呀,应用程序崩溃了!

“!【 bordered width=80%】(images/34-Networking/The-crash-after-searching.png "The crash after searching for "pokemon go"")”

查看Xcode调试器的左边窗格Variables view,您将看到url常量的值为nil——它也可能显示为0x0000……后面跟着一串0。

这个应用程序显然没有创建一个有效的URL对象。但是为什么呢?

URL中的空格不是有效字符。许多其他字符也是无效的——比如<或>符号——因此必须转义(escaped)。另一个术语是URL编码(URL encoding)。

例如,空格可以编码为+符号(您在前面将URL输入web浏览器时就这样做了),或者字符序列%20。

幸运的是,String已经可以进行这种编码了。所以,你只需要在应用程序中添加一个额外的语句就可以实现:

func iTunesURL(searchText: String) -> URL {
  let encodedText = searchText.addingPercentEncoding(
      withAllowedCharacters: CharacterSet.urlQueryAllowed)!
  let urlString = String(format: 
      "https://itunes.apple.com/search?term=%@", encodedText)
  let url = URL(string: urlString)
  return url!
}

这将调用addingPercentEncoding(withAllowedCharacters:)方法来创建一个新字符串,其中所有特殊字符都转义,您将该字符串用于搜索项。

utf - 8编码字符串

这个新字符串将特殊字符视为“UTF-8编码”。知道这是什么意思很重要,因为在处理文本时,偶尔会遇到这个UTF-8。
编码文本有许多不同的方法。您可能听说过ASCII和Unicode,这是两种最常见的编码。
UTF-8是Unicode的一个版本,它对于存储常规文本非常有效,但是对于特殊符号或非西方字母就不那么有效了。尽管如此,它仍然是当今处理Unicode文本最流行的方法。
通常,您不必担心字符串是如何编码的。但是,当向web服务发送请求时,需要使用正确的编码传输文本。提示:当有疑问时,使用UTF-8,它几乎总是有效的。

➤运行应用程序,再次搜索“pokemon go”。这一次可以创建一个有效的URL对象,它看起来像这样:

URL: 'https://itunes.apple.com/search?term=pokemon%20go'

空格已转换为字符序列%20。%表示转义字符,20是空格的UTF-8值。你也可以试着用其他特殊字符搜索词条,比如#和*,甚至是表情符号,看看会发生什么。

执行搜索请求

现在您有了一个有效的URL对象,您就可以进行一些实际的网络连接了!
➤给SearchViewController.swift添加一个新方法。

func performStoreRequest(with url: URL) -> String? {
  do {
   return try String(contentsOf: url, encoding: .utf8)
  } catch {
   print("Download Error: \(error.localizedDescription)")
   return nil
  }
}

这个方法的核心是对String(contentsOf:encoding:)的调用,它返回一个新的String对象,其中包含它从URL指向的服务器接收到的数据。

注意,您正在告诉应用程序将数据解释为UTF-8文本。如果服务器以不同的编码发送回文本,那么在你的应用程序中,它看起来就像一团乱麻。重要的是发送方和接收方同意他们使用的编码!

因为事情可能出错——例如,网络可能宕机,无法访问服务器——所以您将其封装在do-try-catch块中。如果出现问题,代码将跳转到catch分支,错误变量将包含关于错误的更多细节。如果发生这种情况,您将打印出用户可理解的错误形式,并返回nil,以表明请求失败。

➤在print()行之后,将以下几行添加到searchBarSearchButtonClicked(_:)中:

if let jsonString = performStoreRequest(with: url) {
  print("Received JSON string '\(jsonString)'")
}

这将使用URL对象作为参数调用performStoreRequest(with:),并返回从服务器接收到的JSON数据。如果一切正常,从服务器接收。如果一切都按计划进行,这个方法将返回一个包含您正在寻找的JSON数据的新字符串。我们来试试吧!

运行应用程序并搜索你最喜欢的乐队。大约一秒钟后,一大堆数据将被转储到Xcode控制台:

URL: 'http://itunes.apple.com/search?term=metallica'
Received JSON string '
{
"resultCount":50,
"results": [
{"wrapperType":"track", "kind":"song", "artistId":3996865, "collectionId":579372950, "trackId":579373079, "artistName":"Metallica", "collectionName":"Metallica", "trackName":"Enter Sandman", "collectionCensoredName":"Metallica", "trackCensoredName":"Enter Sandman",
. . . and so on . . .

祝贺您,您的应用程序已经成功地与web服务进行了对话!
这将打印与您之前在web浏览器中看到的相同的内容。现在它全部包含在一个String对象中,这对于我们的目的来说并不是很有用,但是您很快就会将它转换成更有用的格式。
当然,您可能收到了错误。在这种情况下,输出应该是这样的:

URL: 'https://itunes.apple.com/search?term=Metallica'
HTTP load failed (error code: -1009 [1:50]) for Task <F5199AB7-5011-42FB-91B5-656244861482>.<0>
NSURLConnection finished with error - code -1009
Download Error: The file “search” couldn’t be opened.

稍后,你会在应用程序中添加更好的错误处理,但如果此时出现这样的错误,那么要确保你的电脑——或者你的iPhone(以防你在设备上而不是模拟器上运行应用程序)连接到互联网。还可以直接在浏览器中尝试URL,看看是否可行。


3.解析JSON

现在您已经成功地从服务器下载了一大块JSON数据,那么如何处理它呢?

JSON是一种结构化数据格式。它通常由数组和字典组成,其中包含其他数组和字典,以及字符串和数字等常规数据。

JSON数据概览

iTunes商店中的JSON大致是这样的:

{
"resultCount": 50,
"results": [ . . . a bunch of other stuff . . . ]
}

{}括号包围着字典。这个特定的字典有两个键:resultCount和results。第一个是resultCount,它有一个数值。这是匹配搜索查询的项数。默认情况下,上限为50项,但正如您稍后将看到的,您可以增加这个上限。

result键包含一个数组,它由[]括号表示。在这个数组中有更多的字典,每个字典描述商店中的一个产品。你可以看出这些东西是字典,因为它们又有{}括号。

以下是数组中的两项:

{
"wrapperType": "track",
"kind": "song",
"artistId": 3996865,
"artistName": "Metallica",
"trackName": "Enter Sandman",
. . . and so on . . .
},
{
"wrapperType": "track",
"kind": "song",
"artistId": 3996865,
"artistName": "Metallica",
"trackName": "Nothing Else Matters",
. . . and so on . . .
}

每个产品都由几个键组成的字典表示。kind和wrapperType键的值决定了这是什么类型的产品:歌曲、音乐视频、有声读物等等。其他的键描述了艺术家和歌曲本身。



简而言之,JSON数据表示一个字典,字典中包含更多的字典数组。数组中的每个字典表示一个搜索结果。
目前,所有这些都位于一个字符串中,这不是很方便,但是使用JSON解析器可以将这些数据转换为Swift字典和数组对象。

JSON还是XML?

JSON不是唯一的结构化数据格式。代表可扩展标记语言的XML是一种稍微正式一些的标准。这两种格式都有相同的目的,但是它们看起来有点不同。如果iTunes商店以XML格式返回结果,输出将更像这样:

<?xml version="1.0" encoding="utf-8"?>
<iTunesSearch>
<resultCount>5</resultCount>
<results>
<song>
<artistName>Metallica</artistName>
<trackName>Enter Sandman</trackName>
</song>
<song>
<artistName>Metallica</artistName>
<trackName>Nothing Else Matters</trackName>
</song>
. . . and so on . . .
</results>
</iTunesSearch>

如今,大多数开发人员更喜欢JSON,因为它比XML更简单,更容易解析。但是,如果您希望您的应用程序与特定的web服务对话,那么您当然有可能需要处理XML数据。

准备解析JSON数据

过去,如果想解析JSON,通常需要在应用程序中包含第三方框架,或者使用内置的iOS JSON解析器手动遍历数据结构。但有了Swift 4,有了一种新的做事方式——你的老朋友是Codable。

为了让您的应用程序能够直接将JSON数据读入相关的数据结构,您所需要做的就是将它们设置为符合Codable!

“等一下”,我听到你说。“Codable如何知道如何从互联网上建立任意的数据结构,以便正确地提取正确的数据位?”啊,这完全取决于如何设置数据结构。当你继续解析从iTunes服务器接收到的数据时,你就会明白了。

用Codable解析JSON数据的诀窍是设置类或结构来反映将要解析的数据的结构。正如您在上面所注意到的,从iTunes服务器接收到的JSON响应有两部分:

  1. 响应包装器(wrapper),其中包含结果的数量和结果数组。
  2. 数组本身由单独的搜索结果项组成。

为了正确解析JSON数据,我们需要对上述两种方法进行建模。在通过SearchResult对象对搜索结果进行建模方面,我们已经取得了一些进展,但是我们需要进行一些修改,以便让对象为JSON解析做好准备。
但首先,让我们为结果包装器添加一个新的数据模型。

➤打开SearchResult.swift ,并将其内容替换为:

class ResultArray:Codable {
    var resultCount = 0
    var results = [SearchResult]()
}

class SearchResult:Codable {
  var artistName: String? = ""
  var trackName: String? = ""
  
  var name:String {
    return trackName ?? ""
  }
}

这里有一些变化:

  1. ResultArray类通过包含一个resultCount计数和一个SearchResult对象数组来对响应包装器建模。注意,该类支持Codable协议。

    如果您想知道为什么这个类与SearchResult在同一个文件中,这只是为了方便起见。除了在JSON解析过程中作为临时持有者之外,其他任何地方都不使用该类。所以我把它放在与SearchResult相同的文件中,这是您将使用的实际类。但如果你愿意,你可以把这个类单独放在一个Swift文件中——这对应用的功能没有任何影响。

  2. SearchResult类现在也支持Codable协议。

  3. 它还有一个名为trackName的新属性,而artistName属性已被更改为可选属性——可选属性是为了使Codable的工作更容易,因为Codable期望非可选值总是出现在JSON数据中。不幸的是,由于iTunes服务器的响应可能并不总是具有这些属性,所以您必须考虑到这一点。

  4. name的现有属性已转换为计算属性,该属性返回trackName属性的值,如果trackName为nil,则返回空字符串。

3和4改变的原因可能不是很明显。查看从服务器接收到的响应数据。你注意到那把“亲切”的key了吗?

iTunes的搜索结果可以是多种类型的商品——歌曲、视频、电影、电视节目、书籍等等。该key指示搜索结果要查找的项的类型。根据项目类型,您可能希望改变显示项目名称的方式。例如,您可能不总是希望使用“trackName”键作为项名——事实上,正如我们上面提到的,“trackName”甚至可能不存在于返回的数据中。computed name属性只是为将来做准备,以防你想要根据结果类型来显示不同的名称。

另外,请注意,现在类中的所有属性名都匹配JSON数据中的实际键——即使属性名与键名不匹配,也可以解析JSON,但这有点复杂。让我们走简单的路。记住,小步……
这就是准备JSON解析所需要的全部内容。开始!

解析JSON数据

您将使用JSONDecoder类解析JSON数据。唯一的问题是,JSONDecoder需要一个Data对象作为它的输入。目前,服务器的JSON响应是一个字符串String。

你可以很容易地将字符串String转换成数据Data,但最好首先从服务器获取数据作为响应——你从服务器获取的响应最初是字符串,只是为了确保响应是正确的。

➤切换到SearchViewController.swift并修改performStoreRequest(with:)如下:

func performStoreRequest(with url: URL) -> Data? {  // Change to Data?
  do {
    return try Data(contentsOf:url)   // Change this line
  } catch {
    . . .
  }
}

您只需更改request方法,以从服务器获取响应作为数据而不是字符串—该方法现在返回值作为可选数据值而不是可选字符串值。

➤将以下方法添加到SearchViewController.swift:

func parse(data: Data) -> [SearchResult] {
  do {
    let decoder = JSONDecoder()
    let result = try decoder.decode(ResultArray.self, from:data)
    return result.results
  } catch {
    print("JSON Error: \(error)")
    return []
  }
}

您使用JSONDecoder对象将响应数据从服务器转换为临时ResultArray对象,并返回其中的results属性。至少,您希望能够毫无问题地转换数据……

假设带来麻烦

当你编写与互联网上其他电脑对话的应用程序时,需要记住的一点是,你的对话伙伴可能并不总是说你希望他们说的话。

服务器上可能有一个错误,它可能返回一些错误消息,而不是有效的JSON数据。在这种情况下,JSONDecoder将无法解析数据,应用程序将从parse返回一个空数组(data:)。

另一种可能发生的情况是,服务器的所有者改变了他们发回数据的格式。通常,这是在可以通过不同URL访问的web服务的新版本中完成的。或者,它们可能要求您发送一个“version”参数。但并不是每个人都这么小心,而且通过改变服务器的操作,他们可能会破坏依赖于以特定格式返回的数据的应用程序。

在iTunes store web服务的情况下,顶层对象应该是一个具有两个键的字典——一个键用于计数,另一个键用于结果数组——但是您不能控制服务器上发生了什么。如果出于某种原因,服务器程序员决定在JSON数据周围加上[]括号,那么顶级对象将不再是字典,而是数组。这将导致JSONDecoder无法解析数据,因为它不再是预期的格式。

对这类事情保持偏执,显示在不太可能发生情况下的错误消息,可比在服务器上发生超出您控制的更改时您的应用程序突然崩溃要好得多。

可以肯定的是,您正在使用do-try-catch块来检查JSON解析是否顺利进行。如果转换失败,那么应用程序不会突然起火,而只是返回一个空的结果数组。

在应用程序中添加这样的检查是很好的,以确保您得到了预期的结果。如果您没有与之通信的服务器,那么最好进行防御性编程。

➤将searchBarSearchButtonClicked(_:)修改如下:

func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
  if !searchBar.text!.isEmpty {
    . . .
    print("URL: '\(url)'")
    if let data = performStoreRequest(with: url) {  // Modified
      let results = parse(data: data)               // New line
      print("Got results: \(results)")              // New line
    }
    tableView.reloadData()
  }
}

您只需将结果的常量改为performStoreRequest(with:),从jsonString转为data,调用新的parse(data:)方法,并print返回的值。

运行应用程序并搜索一些内容。Xcode控制台现在打印以下内容:

URL: 'https://itunes.apple.com/search?term=Metallica'
Got results: [StoreSearch.SearchResult, StoreSearch.SearchResult, StoreSearch.SearchResult, StoreSearch.SearchResult, StoreSearch.SearchResult, StoreSearch.SearchResult, StoreSearch.SearchResult, StoreSearch.SearchResult, StoreSearch.SearchResult, StoreSearch.SearchResult, StoreSearch.SearchResult, StoreSearch.SearchResult, StoreSearch.SearchResult, StoreSearch.SearchResult,
. . . ]

嗯……这看起来确实像一个由50个条目组成的数组,但它并不能真正告诉你关于实际数据的任何信息——只是这个数组由SearchResult对象组成。那对你没多大好处,是吗?

打印对象的内容

➤修改SearchResult中的SearchResult类,使之符合CustomStringConvertible协议:

class SearchResult: Codable, CustomStringConvertible {

CustomStringConvertible协议允许对象具有自定义字符串表示。或者,换句话说,协议允许对象具有描述对象或其内容的自定义字符串。

那么,协议如何提供这个字符串描述呢?这是通过协议的description属性完成的。

➤将以下代码添加到SearchResult类中:

var description: String {
  return "Name: \(name), Artist Name: \(artistName ?? "None")"
}

上面是description属性的实现,以符合CustomStringConvertible。对于SearchResult类,描述由name和artistName属性的值组成——但是由于artistName是一个可选值,所以必须考虑什么时候它可能是nil并输出“None”。

注意到上面代码中的操作符? ?——它被称为nil-coalescing操作符,您可能还记得前面几章中的内容。nil-coalescing操作符将变量展开为操作符的左侧的值(如果它有一个值),否则它将返回操作符右侧的值作为默认值。

再次运行应用程序并搜索一些内容。Xcode控制台现在应该打印如下内容:

URL: 'https://itunes.apple.com/search?term=Metallica'
Got results: [Name: Enter Sandman, Artist Name: Metallica, Name: Nothing Else Matters, Artist Name: Metallica, Name: The Unforgiven, Artist Name: Metallica, Name: One, Artist Name: Metallica, Name: Wherever I May Roam, Artist Name: Metallica,
. . .

是的,看起来更像!

你把一堆毫无意义的JSON转换成了你可以使用的实际对象。

错误处理

让我们添加一个警告来处理潜在的错误。这是不可避免的,有些地方出了问题,最好做好准备。

➤将以下方法添加到SearchViewController.swift:

func showNetworkError() {
  let alert = UIAlertController(title: "Whoops...",
    message: "There was an error accessing the iTunes Store." + 
    " Please try again.", preferredStyle: .alert)
  
  let action = UIAlertAction(title: "OK", style: .default, 
                           handler: nil)
  alert.addAction(action)
  present(alert, animated: true, completion: nil)
}

没有什么是你之前没见过的;它只是提供一个带有错误消息的警报控制器。

注意:message变量被分割成两个单独的字符串,并使用加号(+)操作符连接或添加在一起,以便在本书中很好地显示字符串。你可以把整个字符串作为一个单独的字符串输入。

在返回nil之前,将下面这行添加到performStoreRequest(with:)中:

showNetworkError()

简单地说,如果iTunes商店的请求出了问题,您可以调用showNetworkError()来显示一个警告框。

如果到目前为止您所做的一切都是正确的,那么web服务应该始终能够正常工作。不过,测试一些错误情况仍然是一个好主意,只是为了确保错误处理对那些网络连接不好的倒霉用户有效。

试试这个:在iTunesURL(searchText:)方法中,将URL的“itunes.apple.com”部分临时更改为“NOMOREitunes.apple.com”。

现在,当您尝试搜索时,应该会得到一个错误警告,因为该地址不存在这样的服务器。这模拟iTunes服务器正在关闭。当您完成测试时,不要忘记更改URL。

提示:要模拟没有网络连接,你可以拔掉Mac电脑上的网络电缆和/或禁用Wi-Fi,或者在飞机模式下在设备上运行应用程序。


4.处理JSON结果

到目前为止,您已经成功地向iTunes web服务发送了一个请求,并将JSON数据解析为SearchResult对象数组。然而,我们还没有完全完成。

iTunes商店出售不同种类的产品——歌曲、电子书、软件、电影等等——每一种产品在JSON数据中都有自己的结构。软件产品会有截图,但电影会有视频预览。应用程序将不得不处理这些不同类型的数据。

你不会支持iTunes商店提供的所有东西,只支持这些东西:

  • 歌曲,音乐视频,电影,电视节目,播客
  • 有声书
  • 软件(应用程序)
  • 电子书

我把它们分成这样是因为iTunes商店就是这么做的。例如,歌曲和音乐视频共享同一组字段,但是有声读物和软件具有不同的数据结构。JSON数据使用kind字段进行了这种区分。

让我们修改数据模型来加载上面键的值。

➤将以下属性添加到SearchResult (SearchResult.swift):

var kind: String? = ""

你可能认为“kind”属性总是在iTunes数据中,所以它不需要是可选的。我也这么认为,但不幸的是,iTunes证明我错了:]所以我们在那里添加了一个可选值……

➤还将 description的return行修改为:

return "Kind: \(kind ?? "None"), Name: \(name), Artist Name: \(artistName ?? "None")\n

这是有道理的,因为这是可选的,对吧?

运行应用程序并进行搜索。看看Xcode的输出。

当我这样做的时候,Xcode展示了三种不同类型的产品,大多数结果是歌曲。你看到的可能会有所不同,这取决于你搜索的内容。

URL: 'https://itunes.apple.com/search?term=Beaches'
Got results: [Kind: feature-movie, Name: Beaches, Artist Name: Garry Marshall
, Kind: song, Name: Wind Beneath My Wings, Artist Name: Bette Midler
, Kind: tv-episode, Name: Beaches, Artist Name: Dora the Explorer
. . .

现在,让我们向SearchResult对象添加一些新属性。

总是检查文档

如果你好奇我是如何懂得从iTunes web服务解释数据,甚至如何设置urls使用该服务的,那么您应该意识到如果没有文档,你没有办法使用一个web服务。

幸运的是,对于iTunes store web服务,这里有一些很好的文档:

affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api

但是仅仅阅读文档通常是不够的。你必须稍微摆弄一下web服务,才能知道你能做什么和不能做什么。

对于阅读文档时不清楚的搜索结果,StoreSearch应用程序需要做一些事情。所以,首先阅读文档,然后使用它。这适用于任何API,不管是iOS SDK还是web服务。

加载更多的属性

正如你所看到的,当前的SearchResult类只有几个属性,iTunes商店返回的信息比这多得多,所以你需要添加一些新属性。

➤将以下属性添加到SearchResult.swift中。

var trackPrice: Double? = 0.0
var currency = ""
var artworkUrl60 = ""
var artworkUrl100 = ""
var trackViewUrl: String? = ""
var primaryGenreName = "”

你没有包括iTunes商店返回的所有内容,只包括这个应用程序感兴趣的字段。此外,请注意,你已经为属性命名,以精确匹配JSON数据中的键,只有一些属性被标记为可选。

注:这些属性的可选性是基于我自己的结果。有可能,使用上面的代码,你仍然会发现应用程序到处出现这样的错误:

URL: 'https://itunes.apple.com/search?term=Macky'
JSON Error: keyNotFound(CodingKeys(stringValue: "trackViewUrl", intValue: nil), Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "results", intValue: nil), _JSONKey(stringValue: "Index 1", intValue: 1)], debugDescription: "No value associated with key CodingKeys(stringValue: "trackViewUrl", intValue: nil) ("trackViewUrl").", underlyingError: nil))

如果这种情况发生在您身上,请查看错误消息,找出SearchResult中缺失的属性,然后将其标记为optional—问题解决了!

SearchResult存储商品的价格和货币——美元、欧元、英镑等等。它还存储了两个艺术品的urls,一个用于60×60像素的图像,另一个用于100×100像素的图像,一个指向iTunes商店中该产品页面的链接,以及该产品的类型。

只要类支持Codable,只需简单地添加新属性——只要它们的名称与JSON键相同,并且具有正确的可选性——你现在就可以将这些新值加载到你的类中。但是,如果您不想使用JSON数据中不太友好的名称,如artworkUrl60或artworkUrl100,而是希望使用更具描述性的名称,如artworkSmall和artworkLarge,该怎么办?

不用担心,Codable也支持这一点:]

但在此之前,你应该运行你的应用程序一次,以确保上面的代码更改不会破坏任何东西。因此,运行您的应用程序,进行搜索,并验证您仍然在Xcode控制台中得到输出,表明搜索成功。

所有工作正常吗?太棒了!让我们继续将SearchResults属性命名为您想要的名称,而不是JSON数据集的名称……

支持更好的属性名称

➤替换 SearchResult.swift中的以下代码行:

var artworkUrl60 = ""
var artworkUrl100 = ""
var trackViewUrl: String? = ""
var primaryGenreName = "”

用这个:

var imageSmall = ""
var imageLarge = ""
var storeURL: String? = ""
var genre = ""

enum CodingKeys: String, CodingKey {
  case imageSmall = "artworkUrl60"
  case imageLarge = "artworkUrl100"
  case storeURL = "trackViewUrl"
  case genre = "primaryGenreName"
  case kind, artistName, trackName 
  case trackPrice, currency
}

您将注意到,您已经将属性名称更改得更具描述性,但是enum是做什么的呢?

正如您前面所看到的,枚举(enum或enumeration)是一种方法,用于获得一系列值并为这些值命名。这里,您使用CodingKeys枚举让Codable协议知道您希望如何让SearchResult属性匹配JSON数据。

请注意,如果你使用CodingKeys枚举,它必须为class里所有的属性提供一个case——那些映射到一个JSON键具有相同名称的是enum最后的两个case,你会发现他们没有一个指定的值。

再次运行你的应用程序(可能会改变description属性,返回其中一个新值,以测试它们是否正确显示),并验证代码仍然可以使用这些新属性。

使用结果

使用这些最新的更改,searchBarSearchButtonClicked(_:)检索一个包含有用信息的SearchResult对象数组,但是您还没有对该数组做任何操作。

➤切换到SearchViewController.swift并在searchBarSearchButtonClicked(_:)中,替换以下行:

let results = parse(data: data)
print("Got results: \(results)"))

更改为:

searchResults = parse(data: data)

不需要将结果放在本地变量中并打印出来,而是将返回的数组放入searchResults实例变量中,这样表视图就可以显示实际的搜索结果对象。

运行应用程序,搜索你最喜欢的音乐人。过了一秒钟左右,您应该会看到表中出现了一大堆结果。酷!

不同的数据结构

还记得我说过的一些东西,比如有声读物,有不同的数据结构吗?让我们更详细地讨论一下……

目前,其他项目类型和有声读物之间最大的区别是,有声读物没有为其他项目提供特定的JSON键。这里有一个故障:

  1. kind:这个值根本不存在。
  2. trackName:不是“trackName”,而是“collectionName”。
  3. trackviewUrl:不是这个值,而是“collectionViewUrl”——它提供了到项目的iTunes链接。
  4. trackPrice:不是“trackPrice”,而是“collectionPrice”。

有趣的是,你会注意到在SearchResult中这些都是我们标记为可选的属性。现在你明白为什么我们要把它们标记为可选的了吧?如果你的搜索结果中包含一个有声书条目,那么这些属性就不会出现在那里,因此Codable会进行匹配:]

此外,对于一些项目类型,JSON还有一些其他的区别:

  1. 软件和电子书没有“trackPrice”键,而是有一个“price”键。
  2. 电子书没有“primaryGenreName”键——它们有一系列的类型。

那么,如何修复这些问题,使JSONDecoder能够正确地解码来自iTunes Store服务器的JSON数据,而不管数据的类型是什么呢?您如何处理相同的属性(例如,“trackPrice”)可以呈现为不同的属性(如“collectionPrice”或“price”)的情况?这取决于项目的类型吗?

还记得如何添加一个名为name的计算变量来返回trackName吗?这就是发挥作用的地方……如果您添加另一个变量来存储collectionName(当它是一个有声读物时,它是条目的名称),那么您可以根据情况从name返回正确的值。你也可以对商店的网址和价格做类似的事情。

让我们做出必要的改变。

➤从SearchResult中移除storeURL属性——你将为有声书和非有声书类型添加两个单独的可选属性。还要从CodingKeys中删除storeURL案例。

➤从SearchResult中移除genre属性——你将为电子书和非电子书类型添加两个单独的可选属性。还可以从CodingKeys中删除类型案例。

➤为上述特殊项目中出现的不同键添加新的可选属性:

var trackViewUrl: String?
var collectionName: String?
var collectionViewUrl: String?
var collectionPrice: Double?
var itemPrice: Double?
var itemGenre: String?
var bookGenre: [String]?

➤将name的计算属性(computed property)替换为:

var name: String {
  return trackName ?? collectionName ?? ""
}

除了nil-coalescing操作符的链接之外,更改非常简单。检查trackName是否为nil——如果不是,则返回trackName的未包装值。如果trackName为nil,则继续到collectionName并执行相同的检查。如果两个值都为nil,则返回一个空字符串。

➤添加以下三个新的计算属性:

var storeURL: String {
  return trackViewUrl ?? collectionViewUrl ?? ""
}

var price: Double {
  return trackPrice ?? collectionPrice ?? itemPrice ?? 0.0
}

var genre: String {
  if let genre = itemGenre {
    return genre
  } else if let genres = bookGenre {
    return genres.joined(separator: ", ")
  }
  return ""
}

前两个计算属性的工作方式类似于name计算属性的工作方式。这没什么新鲜的。genre属性只返回非电子书项的类型。对于电子书,该方法将数组中由逗号分隔的所有类型值组合起来,然后返回组合后的字符串。

剩下的就是将所有的新属性添加到CodingKeys枚举中——如果不这样做,在JSON解码期间可能无法正确填充一些值。完成之后,CodingKeys应该是这样的:

enum CodingKeys: String, CodingKey {
  case imageSmall = "artworkUrl60"
  case imageLarge = "artworkUrl100"
  case itemGenre = "primaryGenreName"
  case bookGenre = "genres"
  case itemPrice = "price"
  case kind, artistName, currency
  case trackName, trackPrice, trackViewUrl
  case collectionName, collectionViewUrl, collectionPrice
}

再次运行应用程序,搜索像“Stephen King”这样的单词,确保得到一些结果,其中包括《恐怖大师》的有声读物!如果你想知道为什么这个特定的搜索词,我们要找有声读物,因为有声读物是数据结构变化的一种类型……

显示产品类型

搜索结果可能包括播客、歌曲或其他相关产品。让表格视图显示它所显示的产品类型是很有用的。

仍然在SearchResult.swift中,添加以下计算属性:

var type: String {
  return kind ?? "audiobook"
}

var artist: String {
    return artistName ?? ""
} 

记住,如果项目类型是audiobook,而我们将artistName标记为可选,用这些新的计算性质来对应。

➤打开SearchViewController.swift并在tableView(_:cellForRowAt:)中,改变设置单元格的行。以下是艺术家的名字:

if searchResult.artist.isEmpty {
  cell.artistNameLabel.text = "Unknown"
} else {
  cell.artistNameLabel.text = String(format: "%@ (%@)", 
            searchResult.artist, searchResult.type)
}

第一个变化是现在检查SearchResult的artist是否为空。在测试该应用程序时,我注意到有时搜索结果不包含艺术家的名字。在这种情况下,让单元格显示“Unknown”。

您还将new type属性的值添加到艺术家名称标签中,该属性应该告诉用户他们正在查看的是哪种产品:

这有一个问题。kind的值直接来自服务器,它更像是一个内部名称,而不是您希望直接显示给用户的东西。

如果你想让它说“Movie”,或者你想把这个应用翻译成另一种语言——你稍后会在StoreSearch中做些什么。最好将这个内部标识符“feature-movie”转换为要显示给用户的文本“Movie”。

➤用这个替换SearchResult.swif中type的计算属性:

var type:String {
  let kind = self.kind ?? "audiobook"
  switch kind {
  case "album": return "Album"
  case "audiobook": return "Audio Book"
  case "book": return "Book"
  case "ebook": return "E-Book"
  case "feature-movie": return "Movie"
  case "music-video": return "Music Video"
  case "podcast": return "Podcast"
  case "software": return "App"
  case "song": return "Song"
  case "tv-episode": return "TV Episode"
  default: break
  }
  return "Unknown"
}

这些都是这个应用程序能够理解的产品类型。

有可能我漏掉了一个,或者iTunes商店在某个时候添加了一个新产品类型。如果发生这种情况,switch切换到default:case,你只需要返回一个字符串,上面写着“Unknown”——希望能在应用程序的更新中帮助识别和修复未知类型。

default和break

Switch语句通常有一个default:case 在最后只显示break。
在Swift中,switch必须是详尽的,这意味着它必须对所查看的对象的所有可能值都有一个case。
这里你看到的是kind。Swift需要知道当kind不是任何已知值时该怎么做。这就是为什么需要包含default: case,作为其他任何类型的可能值的集合。
顺便说一下:与其他语言不同,Swift中的case语句不需要在结尾处使用break。它们不会像Objective-C那样自动地从一种情况切换到另一种情况。

现在项目类型不应该显示为来自web服务的值,而应该显示为您为每个项目类型设置的值:

运行该应用程序,搜索软件、音频书籍或电子书,看看解析代码是否有效。由于商店里有大量的商品,你可能需要几次尝试才能找到一些。

稍后,你会添加一个控件,让你选择想要搜索的产品类型,这让你更容易找到电子书或有声读物。


5. 搜索结果排序

按字母顺序排列搜索结果会很好。这其实很简单。一个Swift数组已经有了自己的排序方法。你所要做的就是告诉它要分类什么。

➤SearchViewController.swift,在searchBarSearchButtonClicked(_:)中,在调用parse(data:)后添加以下内容:

searchResults.sort(by: { result1, result2 in
  return result1.name.localizedStandardCompare(
         result2.name) == .orderedAscending
})

获取结果数组后,对searchResults数组调用sort(by:),并使用一个闭包确定排序规则。这与在检查表中对待办事项列表进行排序是相同的。

为了对searchResults数组的内容进行排序,闭包将相互比较SearchResult对象,如果result1在result2之前,则返回true。在不同对SearchResult对象上重复调用闭包,直到数组完全排序。

这两个对象的比较使用localizedStandardCompare()来比较SearchResult对象的名称。因为您使用了. orderedrising,所以只有当result1.name位于result2.name之前时,闭包才返回true。

运行应用程序并验证搜索结果是按字母顺序排列的。


排序很容易添加,但还有一种更简单的方法。

改进排序代码

➤修改你刚刚添加的排序代码:

searchResults.sort { $0.name.localizedStandardCompare($1.name) 
                     == .orderedAscending }

它使用尾随闭包语法将闭包放在方法名之后,而不是放在传统的()括号内作为参数。这是可读性上的一个小进步。

更重要的是,在闭包中不再按名称引用两个SearchResult对象,而是作为特殊的0和1变量。在Swift闭包中,使用这种简写而不是完整的参数名是很常见的。这也不再有返回语句。

➤验证一下这是否有效。

信不信由你,你可以做得更好。Swift有一个非常酷的功能,叫做操作符重载(operator overloading)。它允许您使用诸如+或*之类的标准操作符,并将它们应用于您自己的对象。你甚至可以创建全新的运算符符号。

过度使用这个特性并让操作符做一些完全出乎意料的事情不是一个好主意——不要超载/不要做乘法。-但它对分类很方便。

➤打开SearchResult.swift并在class外添加以下代码:

func < (lhs: SearchResult, rhs: SearchResult) -> Bool {
  return lhs.name.localizedStandardCompare(rhs.name) == 
         .orderedAscending
}

这看起来应该很眼熟!您正在创建一个名为 < 的函数,该函数包含与前面的闭包相同的代码。这一次,左边和右边的SearchResult对象分别称为lhs和rhs。

现在,您已经重载了小于操作符,因此它接受两个SearchResult对象,如果第一个对象在第二个对象之前,则返回true,否则返回false。像这样:

searchResultA.name = "Waltz for Debby"
searchResultB.name = "Autumn Leaves"

searchResultA < searchResultB  // false
searchResultB < searchResultA  // true

回到SearchViewController.swift,将排序代码改为:

searchResults.sort { $0 < $1 }

这是很sweet的。使用 < 操作符可以非常清楚地看出,您正在按升序对数组中的项进行排序。
但是等一下,你可以写得更短:

searchResults.sort(by: <)

哇,没有比这更简单的了!”这一行的字面意思是“按升序排列这个数组”。当然,这只是因为您添加了自己的func <来重载小于操作符,所以它接受两个SearchResult对象并对它们进行比较。

再次运行应用程序,确保一切都是有序的。

练习:看看你能否让应用程序按艺术家的名字排序。
练习:试着按降序排序,从Z到a。提示:使用>操作符。

太好了!您让应用程序与web服务对话,并且能够将接收到的数据转换为您自己的数据模型对象。

这个应用程序可能不支持iTunes商店中显示的所有产品,但我希望它能说明一个原则,即如何将以略微不同形式出现的数据转换成更便于在自己的应用程序中使用的对象。

您可以随意挖掘web服务API文档,添加iTunes商店销售的其他项目:https://ate.itunes.apple.com/resources/documentation/itunes-store-webservice -search-api/

➤用诸如“使用同步网络请求从web服务中添加抓取数据”这样的信息commit你的修改。

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

推荐阅读更多精彩内容

  • #爱的五种语言# 记录爱箱满满的一天 1. 一大早去体检,一切顺利,遇见热情体贴的体检人员,开心 2. 老公今天一...
    双子座的尾巴阅读 154评论 0 0
  • 现在写的是昨天到此刻。 昨天她神奇的要去ex那拿回端午节放在他那的两千块钱,跟她商量时她又说气话,什么不要了之类的...
    燃烧羊羊阅读 217评论 3 1
  • 说出来你可能不信,我从记事起就是那种别人家的孩子。一岁半开始写字背诗,早早开始上幼儿园,是老师们最得意的乖学生。什...
    食爱女阅读 205评论 0 1
  • 主题: 盘点2018,寄语2019. ---携手战友,不忘初心,砥砺前行。 时间: 2019.1.1 07:00a...
    CoryLiu阅读 246评论 0 1