Swift:用NSURLSession下载iTunes歌曲

在 swift 中使用 NSURLSession 时,看到了一篇 文章 使用 NSURLSession 从 iTunes 下载歌曲,也包含暂停、继续下载、模拟进度、取消下载的功能。但文章中一些技术细节稍微老旧了些,故,在这里重新整理一下,方便日后学习。
完整项目地址 TracksDownload-iTunes

准备工作
  • Xcode 版本要求 7.3 及以上,我用的Xcode7.3,OS X 版本要求 10.11.0 及以上
  • 这里 下载基础工程。解压缩,运行程序,会看到一个基本的界面,界面上有个 SearchBar 和空的 TableView,如下图
NSRULSession 简介

NSRULSession 在技术上不仅是一个类,而且也是一套处理基于 HTTP/HTTPS 请求的类。通过下图来了解一下它的构成

NSRULSession 是收、发 HTTP 请求的关键对象,它可以通过一个配置体 NSURLSessionConfiguration 创建。这个配置体可以设置 session 的超时时间,缓存策略,以及 HTTP headers ,它可以由三种方式创建:

  • defaultSessionConfiguration:通过这个方法生成的对象,会用默认的方式管理上传和下载的任务 ,并本地持久化 cache,cookie 和 信任证书
  • ephemeralSessionConfiguration:和上面的方法类似,区别在于它会把会话相关的数据最优化的存储在内存中,并从内存中取这些数据
  • backgroundSessionConfiguration:系统会把上传或下载任务放在单独的进程,允许这些任务在后台进行,及时这个 app 被后台挂起或终止,session 的传输也不会停止(如果你双击home键,向上滑动 app 进行关闭,那么所有的 session 都会中断)

NSRULSession 的所有的任务都需要关联一个任务 NSURLSessionTask对象,这个对象是任务的实际执行者,进行数据的获取,下载或上传文件。这个对象有三种类型:

  • NSURLSessionDataTask:用这种类型的对象做 HTTP GET 请求,从服务器检索数据,并存到内存中
  • NSURLSessionUploadTask:用这种类型的对象把磁盘中的文件上传到服务器,典型地,通过 HTTP POST 或 PUT 方法
  • NSURLSessionDownloadTask:用这种类型的对象从服务器下载文件,并存到一个临时的文件地址

你可以暂停、继续和取消一个任务。NSURLSessionDownloadTask 支持任务暂停,并在以后继续下载

一般地,NSURLSession 通过两种方式返回数据:一. 任务完成或失败后,通过一个 completionHandler 块返回数据;二. 在创建 session 时,指定一个代理方法,任务结束后通过回调方法返回数据

了解了 NSURLSession 的基本知识后,接下来开始实际操作

查询歌曲

要查询歌曲,需要借助 iTunes Search API ,在 UISearchBar 中,输入关键字,然后点击回车,进行搜索。

首先,在 SearchViewController.swift 中,在

var searchResults = [TrackModel]()

下面 添加以下代码:

// 歌曲查询 session 和 task
let session_queryTracks = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration())
var task_queryTracks: NSURLSessionTask?
  • 第一句,我们通过默认的 configuration 生成了一个 NSURLSession 对象
  • 第二句,声明一个 NSURLSessionTask 类型变量,用它进行 HTTP GET 请求,从 iTunes 的服务器查询歌曲。每次用户发起新的查询时,这个变量都会被重新初始化并循环使用

然后,需要借助 UISearchBar 的代理方法 searchBarSearchButtonClicked(_:),来捕获用户的搜索行为。在 SearchViewController.swift 中找到这个代理方法,更新为如下代码:

func searchBarSearchButtonClicked(searchBar: UISearchBar) {
    
    dismissKeyboard()

    let searchString = searchBar.text!.stringByTrimmingCharactersInSet(NSCharacterSet.whitespaceAndNewlineCharacterSet())

    if !searchString.isEmpty {
        // 1
        if task_queryTracks != nil {
            task_queryTracks?.cancel()
        }
        // 2   
        UIApplication.sharedApplication().networkActivityIndicatorVisible = true
        
        // 3 设置允许包含在搜索关键词中的字符
        let expectedCharSet = NSCharacterSet.URLQueryAllowedCharacterSet()
        let searchTerm = searchString.stringByAddingPercentEncodingWithAllowedCharacters(expectedCharSet)
        // 4
        let urlString = "http://itunes.apple.com/search?media=music&entity=song&term=\(searchTerm!)"
        let url = NSURL(string: urlString)
        // 5 生成查询任务对象
        task_queryTracks = session_queryTracks.dataTaskWithURL(url!, completionHandler: { [unowned self](data, response, error) in
            // 6
            dispatch_async(dispatch_get_main_queue(), {
                UIApplication.sharedApplication().networkActivityIndicatorVisible = false
            })
            // 7
            if let error = error {
                print(error.localizedDescription)
            }
            else if let httpResponse = response as? NSHTTPURLResponse {
            
                if httpResponse.statusCode == 200 {
                    self.updateSearchResults(data)
                }
            }
        })
        // 8 开始查询
        task_queryTracks?.resume()
    }
  }

按着上面的注释标号,依次说明一下:

  • //1. 每次用户查询时,都会检查 task_queryTracks 是否已经初始化,如果初始化了,那么就取消上一次搜索任务,以便开始新的任务搜索,并重新利用 task_queryTracks
  • //2. 在状态栏显示小菊花,告诉用户,系统正在进行网络任务
  • //3. 搜索的关键字被传入 URL 前,把一些不被允许的字符过滤掉
  • //4. 根据 iTunes Search API ,把处理过的内容当做 GET 请求的参数,生成一个 NSURL 对象
  • //5. 初始化一个 NSURLSessionDataTask 对象,来处理 HTTP GET 请求,任务完成后,数据会在 completionHandler 块中返回
  • //6. 在主线程隐藏状态栏的菊花,表明网络任务结束
  • //7. 如果成功了,则调用方法 updateSearchResults(_:) 来处理收到的 NSData 数据,并更新 TableView
  • //8. 调用 resume() 开始搜索任务

运行 app,可以搜索任意一首歌,比如输入 Swift,回车搜索,会出现下图的效果:

准备下载歌曲

下载歌曲时,为了允许用户暂停、继续、取消下载,并且能显示下载进度,我们建立一个下载的 Model ,来保存下载状态。在 Model 文件夹下,新建类文件,命名为 DownloadModel 如图:

在文件 DownloadModel.swift 中,添加以下代码:

class DownloadModel {
    
    var downloadUrl: String
    var isDownloading = false
    var downloadProgress = 0.0
    
    var downloadTask: NSURLSessionDownloadTask?
    var downloadResumeData: NSData?
    
    init(downloadUrl: String) {
        self.downloadUrl = downloadUrl
    }
}

简单介绍一下这些属性:

  • downloadUrl :歌曲的下载地址,唯一标识一个 DownloadModel
  • isDownloading : 歌曲是否正在下载
  • downloadProgress:歌曲下载进度,0.0~1.0
  • downloadTask:歌曲下载的一个 Task 对象
  • downloadResumeData:暂停时,得到的恢复数据,包含继续下载的信息(iTunes 服务器支持断点下载)
建立下载任务

有了这个 Model 之后,为了追踪每一个下载任务,切换到 SearchViewController.swift 文件,找到

var searchResults = [TrackModel]()

在它下面一行,添加以下代码:

var trackDownload = [String: DownloadModel]()
lazy var session_downloadTracks: NSURLSession = {
        
        let configuration = NSURLSessionConfiguration.defaultSessionConfiguration()
        let session = NSURLSession(configuration: configuration, delegate: self, delegateQueue: nil)
        return session
    }()
  • 第一句是做了一个下载的映射,唯一的 url 对应一个下载 Model,来追踪歌曲的下载状态
  • 第二句生成下载歌曲的 Session,这个 Session 只用于生成下载歌曲用的 NSURLSessionDownloadTask。其中,设置了代理,来处理与 Session 相关的事件,比如可以在代理方法中得到下载的进度,数据等。我们设置 delegateQueue 为 nil,默认的,系统会在一个串行队列中进行代理方法的调用以及执行的结果方法调用
  • 使用 lazy 关键字,系统不立刻生成 session_downloadTracks 这个对象,而是我们使用它时,系统才去创建

接下来,来实现 NSURLSession 的代理方法。在文件 SearchViewController.swift 的最底部,加入以下代码:

extension SearchTracksViewController: NSURLSessionDownloadDelegate {
    
    func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didFinishDownloadingToURL location: NSURL) {
        
        print("下载结束")
    }
}
  • NSURLSessionDownloadDelegate 定义了使用 NSURLSession 下载某些任务时,用到的代理方法。一个下载任务结束的时候,方法 URLSession(_:downloadTask:didFinishDownloadingToURL:) 都会被调用。

我们来触发下载任务。当用户点击 Download 按钮时,会调用方法 startDownload(_:),在此方法中执行下载任务,找到这个方法,更新为以下代码:

func startDownload(track: TrackModel) {
        
        if let urlString = track.trackPreviewUrl, url = NSURL(string:urlString) {
            
            let download = DownloadModel(downloadUrl: urlString)
            
            download.downloadTask = session_downloadTracks.downloadTaskWithURL(url)
            download.downloadTask?.resume()
            download.isDownloading = true
    
            trackDownload[urlString] = download
        }
    }
  • 当用户点击下载的时候,在此方法中生成一个 DownloadModel 对象,保存了下载中的歌曲状态,并映射到 字典trackDownload

运行这个 app ,搜索任意一首歌,点击下载,过一会就会收到一条打印信息:"下载结束"。表示下载结束。

保存并播放歌曲

歌曲下载完之后,会调用方法 URLSession(_:downloadTask:didFinishDownloadingToURL:) 。方法里有个参数 URL,是文件的临时存放地址,我们要做到的就是把这个文件拷贝一个指定的地址(本地持久化)。然后,我们需要把已经完成的任务从字典 trackDownload 中移除,并更新相应的 tableViewCell。

为了方便找到对应的 cell,我们在 SearchViewController.swift 文件中添加一个辅助方法,用来返回 cell 所在的索引 index。代码如下:

func cellIndexOfDownloadTrack(downloadTrack:NSURLSessionDownloadTask) -> Int? {
        
        if let url = downloadTrack.originalRequest?.URL?.absoluteString {
            
            for (index, track) in searchResults.enumerate() {
                
                if url == track.trackPreviewUrl {
                    return index
                }
            }
        }
        return nil
    }

下一步就要开始把文件拷贝到我们指定的地址。更新代理方法 URLSession(_:downloadTask:didFinishDownloadingToURL:) 如下:

func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didFinishDownloadingToURL location: NSURL) {
        
        // 1
        let originalURL: String? = downloadTask.originalRequest?.URL?.absoluteString
        if let url = originalURL, destinationURL = localFilePathForUrl(url) {
            
            print(destinationURL)
            
            // 2
            let fileManager = NSFileManager.defaultManager()
            do {
                try fileManager.removeItemAtURL(destinationURL)
            } catch {
                //
            }
            
            do {
                try fileManager.copyItemAtURL(location, toURL: destinationURL)
            } catch let error as NSError {
                print("Could not copy file to disk:\(error.localizedDescription)")
            }
        }
        
        // 3
        if let url = originalURL {
            
            trackDownload[url] = nil
            // 4
            if let index = cellIndexOfDownloadTrack(downloadTask) {
                dispatch_async(dispatch_get_main_queue(), {
                    
                    [unowned self] in
                    self.tableView.reloadRowsAtIndexPaths([NSIndexPath(forRow: index,inSection: 0)], withRowAnimation: .None)
                })
            }
        }
    }

对于上面代码标注的关键步骤,做一个简单说明:

  • //1. 我们提取出下载任务的原始 URL,然后找到 app 的 Documents 路径,在这个路径后拼接原始 URL的lastPathComponent,得到一个新的路径,就是我们需要的目标路径
  • //2. 把文件从临时路径 location 拷贝到目标路径之前,先使用 NSFileManager 清理目标路径下的数据,然后再执行拷贝
  • //3. 从数据结构中删除这个不再需要的 downloadTask 对象
  • //4. 根据索引,更新相应的 tableviewCell

运行 app,搜索一首歌,点击下载,稍等片刻就会收到一条打印信息:


下载按钮也会消失,点击已经下载的歌曲,就会弹出 MPMoviePlayerViewController 进行播放,如图:

模拟下载进度

模拟下载进度时,我们需要知道两点:

  • 已接收的数据量
  • 总数据量

协议 NSURLSessionDownloadDelegate 的代理方法中,有一个方法带有我们需要的这两个参数,在文件 SearchViewController.swift 中,找到对这个协议的扩展,添加下面的方法:

func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
        
        // 1
        if let url = downloadTask.originalRequest?.URL?.absoluteString, trackDownload = trackDownload[url] {
            
            // 2
            trackDownload.downloadProgress = Float(totalBytesWritten)/Float(totalBytesExpectedToWrite)
            // 3
            let totalSize = NSByteCountFormatter.stringFromByteCount(totalBytesExpectedToWrite, countStyle: .Binary)
            // 4
            if let index = cellIndexOfDownloadTrack(downloadTask), trackCell = tableView.cellForRowAtIndexPath(NSIndexPath(forRow: index, inSection: 0)) as? TrackCell {
                
                dispatch_async(dispatch_get_main_queue(), { 
                    trackCell.v_progress.progress = trackDownload.downloadProgress
                    trackCell.lb_progress.text = String(format: "%.1f%% of %@", trackDownload.downloadProgress*100,totalSize)
                })
            }
        }
    }

接下来一步步分析代码中的标注:

  • //1. 使用参数 downloadTask,提取其中的 URL,然后根据 URL 找到对应的下载 Model
  • //2. 这一步是关键,参数 totalBytesWritten 代表已经接收并写入临时文件的数据,参数 totalBytesExpectedToWrite 代表总数据量,两个值求商就是当前的下载比例。然后保存到下载 Model 的 downloadProgress 属性中
  • //3. NSByteCountFormatter 可以把数据量转换为人们易懂的字节数,比如转换后变为 50 KB
  • //4. 最后找到这首歌曲对应的 cell,然后更新 cell 上的进度等

为了在 cell 上正确的显示下载状态,找到方法 tableView(_:cellForRowAtIndexPath:),在

let track = searchResults[indexPath.row]

下面添加代码:

        var showDownloadControls = false
        if let download = trackDownload[track.trackPreviewUrl!] {
            
            showDownloadControls = true
            cell.v_progress.progress = download.downloadProgress
            cell.lb_progress.text = download.isDownloading ? "Downloading..." : "Paused"
        }
        cell.v_progress.hidden = !showDownloadControls
        cell.lb_progress.hidden = !showDownloadControls

对于将要下载的歌曲,显示 “Downloading...”,暂停的显示 “Paused”,并且根据下载状态隐藏or显示 v_progress 和 lb_progress。对于正在下载的歌曲,下载按钮也要隐藏,所以,这句代码

cell.btn_download.hidden = trackHaveDownloaded

更新为

cell.btn_download.hidden = trackHaveDownloaded || showDownloadControls

运行 app,下载一首歌,看一下下载效果,如图所示:


暂停、继续、取消下载

......

暂停

......

暂停时,会产生恢复数据 resume data,根据这里面的数据,可以在以后继续下载,前提是服务器支持断点下载。

并且不是所有的条件下都可以继续下载的,具体哪些情况可以继续下载,请参考 文档

找到方法 pauseDownload(_:),更新为以下代码:

func pauseDownload(track: TrackModel) {
        
        if let url = track.trackPreviewUrl, download = trackDownload[url] {
            
            if download.isDownloading {
                download.downloadTask?.cancelByProducingResumeData({ (data) in
                    
                    if data != nil {
                        download.downloadResumeData = data
                    }
                })
                download.isDownloading = false
            }
        }
    }

上面的代码中,通过调用方法 cancelByProducingResumeData(_:),得到了 resume data,然后把这个 data 保存到相应的下载 Model 中,方便以后继续下载。并更新 Model 中的属性 isDownloading,表示停止下载。

......

继续

......

找到方法 resumeDownload(_:) ,更新为以下代码:

func resumeDownload(track: TrackModel) {
        
        if let previewUrl = track.trackPreviewUrl, download = trackDownload[previewUrl] {
            
            if let resumeData = download.downloadResumeData {
                
                download.downloadTask = session_downloadTracks.downloadTaskWithResumeData(resumeData)
                download.downloadTask!.resume()
                download.isDownloading = true
            }
            else if let url = NSURL(string: download.downloadUrl) {
                
                download.downloadTask = session_downloadTracks.downloadTaskWithURL(url)
                download.downloadTask!.resume()
                download.isDownloading = true
            }
        }
    }

在这个方法中,我们判断如果有 resume data,那么调用方法 downloadTaskWithResumeData(_:) 来继续下载。如果没有,就重新下载 。两种情况下,都更新下载状态为 true。

......

取消

......

取消下载就比较简单了,找到方法 cancelDownload(_:) ,更新为以下代码:

func cancelDownload(track: TrackModel) {
        
        if let url = track.trackPreviewUrl, download = trackDownload[url] {
            
            download.downloadTask?.cancel()
            trackDownload[url] = nil
        }
    }

在这个方法中,找到需要取消的下载任务,然后调用方法 cancel() 就会取消下载,并从字典中删掉这个任务。

最后要做的就是更新 cell 的工作了。回到方法 tableView(_:cellForRowAtIndexPath:) ,在 if 块中,添加下面的代码:

let title = download.isDownloading ? "Pause" : "Resume"
cell.btn_pause.setTitle(title, forState: .Normal)

cell.lb_progress.hidden = !showDownloadControls

下面添加以下代码:

cell.btn_pause.hidden = !showDownloadControls
cell.btn_cancel.hidden = !showDownloadControls

整个工作到此结束,运行 app,下载几首歌,并进行暂停,恢复,取消,效果如下图所示:

总结

在建立 DownloadModel 的时候,里面的 DownloadModel 最好是个 class 类型,而不要声明为 struct 类型,正如本项目中建立的一样。因为 struct 类型是 value typeclass 类型是 reference type

它们之间的区别请查看 Swift: 概念解释

本项目中,会对 DownloadModel 的对象所持有的属性,比如 isDownloading 等进行多次的修改。如果 DownloadModel 是 struct 类型,那么每次修改过之后,都需要再更新一遍字典 trackDownload 中对应的 model,因为 struct 类型的对象在传递的过程中,是重新拷贝一份的,拷贝后得到的数据并不指向原始地址。而 class 类型是 引用类型,故在传递过程中,这个对象都是指向原始地址的,对它的修改,也会影响原始数据。

我们可以对比一下 DownloadModl 为 class 类型和 struct 类型两种情况下,代码的差异性:

  • DownloadModl 为 class 类型:
func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
        
        // 1
        if let url = downloadTask.originalRequest?.URL?.absoluteString, trackDownload = trackDownload[url] {
            
            // 2
            trackDownload.downloadProgress = Float(totalBytesWritten)/Float(totalBytesExpectedToWrite)
            // 3
            let totalSize = NSByteCountFormatter.stringFromByteCount(totalBytesExpectedToWrite, countStyle: .Binary)
            // 4
            if let index = cellIndexOfDownloadTrack(downloadTask), trackCell = tableView.cellForRowAtIndexPath(NSIndexPath(forRow: index, inSection: 0)) as? TrackCell {
                
                dispatch_async(dispatch_get_main_queue(), { 
                    trackCell.v_progress.progress = trackDownload.downloadProgress
                    trackCell.lb_progress.text = String(format: "%.1f%% of %@", trackDownload.downloadProgress*100,totalSize)
                })
            }
        }
    }
  • DownloadModl 为 struct 类型:
func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
        
        // 1
        if let url = downloadTask.originalRequest?.URL?.absoluteString {
            
            download = trackDownload[url]! as DownloadModl
            // 2
            download.downloadProgress = Float(totalBytesWritten)/Float(totalBytesExpectedToWrite)
            // 3
            let totalSize = NSByteCountFormatter.stringFromByteCount(totalBytesExpectedToWrite, countStyle: .Binary)
            // 4
            if let index = cellIndexOfDownloadTrack(downloadTask), trackCell = tableView.cellForRowAtIndexPath(NSIndexPath(forRow: index, inSection: 0)) as? TrackCell {
                
                dispatch_async(dispatch_get_main_queue(), { 
                    trackCell.v_progress.progress = download.downloadProgress
                    trackCell.lb_progress.text = String(format: "%.1f%% of %@", download.downloadProgress*100,totalSize)
                })
            }
           trackDownload[url] = download
        }
    }

注意区分上面两种情况下,使用 struct 类型会方便很多,不然,类似的还有方法 pauseDownload(_:)resumeDownload(_:)等,都需要做相应调整。

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

推荐阅读更多精彩内容