iOS音視頻實現邊播邊緩存的思路和解決方案(轉)

本片為轉載內容,主要是以后自己看起來方便一些
原文地址: iOS音視頻實現邊下載邊播放
其實音視頻本地緩存的思想都差不多,都需要一個中間對象來連接播放器和服務器。
近段時間制作視頻播放社區的功能,期間查找了不少資料,做過很多嘗試,現在來整理一下其中遇到的一些坑.由于考慮到AVPlayer對視頻有更高自由度的控制,而且能夠使用它自定義視頻播放界面,iOS中所使用的視頻播放控件為AVPlayer,而拋棄了高層次的MediaPlayer框架,現在想想挺慶幸當初使用了AVPlayer。

AVPlayer的基本知識

AVPlayer本身并不能顯示視頻,而且它也不像MPMoviePlayerController有一個view屬性。如果AVPlayer要顯示必須創建一個播放器層AVPlayerLayer用于展示,播放器層繼承于CALayer,有了AVPlayerLayer之添加到控制器視圖的layer中即可。要使用AVPlayer首先了解一下幾個常用的類:
AVAsset:主要用于獲取多媒體信息,是一個抽象類,不能直接使用。
AVURLAsset:AVAsset的子類,可以根據一個URL路徑創建一個包含媒體信息的AVURLAsset對象。
AVPlayerItem:一個媒體資源管理對象,管理者視頻的一些基本信息和狀態,一個AVPlayerItem對應著一個視頻資源。
iOS視頻實現邊下載邊播放的幾種實現

1.本地實現http server

在iOS本地開啟Local Server服務,然后使用播放控件請求本地Local Server服務,本地的服務再不斷請求視頻地址獲取視頻流,本地服務請求的過程中把視頻緩存到本地,這種方法在網上有很多例子,有興趣了解的人可自己下載例子查看。

2.使用AVPlayer的方法開啟下載服務
 1.AVURLAsset *urlAsset = [[AVURLAsset alloc]initWithURL:url options:nil];
 2.AVPlayerItem *item = [AVPlayerItem playerItemWithAsset:urlAsset];
 3.[self.avPlayer replaceCurrentItemWithPlayerItem:item];
 4.[self addObserverToPlayerItem:item];

但由于AVPlayer是沒有提供方法給我們直接獲取它下載下來的數據,所以我們只能在視頻下載完之后自己去尋找緩存視頻數據的辦法,AVFoundation框架中有一種從多媒體信息類AVAsset中提取視頻數據的類AVMutableComposition和AVAssetExportSession。
其中AVMutableComposition的作用是能夠從現有的asset實例中創建出一個新的AVComposition(它也是AVAsset的字類),使用者能夠從別的asset中提取他們的音頻軌道或視頻軌道,并且把它們添加到新建的Composition中。
AVAssetExportSession的作用是把現有的自己創建的asset輸出到本地文件中。
為什么需要把原先的AVAsset(AVURLAsset)實現的數據提取出來后拼接成另一個AVAsset(AVComposition)的數據后輸出呢,由于通過網絡url下載下來的視頻沒有保存視頻的原始數據(或者蘋果沒有暴露接口給我們獲?。?,下載后播放的avasset不能使用AVAssetExportSession輸出到本地文件,要曲線地把下載下來的視頻通過重構成另外一個AVAsset實例才能輸出。代碼例子如下:

NSString *documentDirectory = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0];
NSString *myPathDocument = [documentDirectory stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.mp4",[_source.videoUrl MD5]]];


NSURL *fileUrl = [NSURL fileURLWithPath:myPathDocument];

if (asset != nil) {
AVMutableComposition *mixComposition = [[AVMutableComposition alloc]init];
AVMutableCompositionTrack *firstTrack = [mixComposition addMutableTrackWithMediaType:AVMediaTypeVideo preferredTrackID:kCMPersistentTrackID_Invalid];
[firstTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, asset.duration) ofTrack:[[asset tracksWithMediaType:AVMediaTypeVideo]objectAtIndex:0] atTime:kCMTimeZero error:nil];

AVMutableCompositionTrack *audioTrack = [mixComposition addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:kCMPersistentTrackID_Invalid];
[audioTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, asset.duration) ofTrack:[[asset tracksWithMediaType:AVMediaTypeAudio]objectAtIndex:0] atTime:kCMTimeZero error:nil];

AVAssetExportSession *exporter = [[AVAssetExportSession alloc]initWithAsset:mixComposition presetName:AVAssetExportPresetHighestQuality];
exporter.outputURL = fileUrl;
if (exporter.supportedFileTypes) {
exporter.outputFileType = [exporter.supportedFileTypes objectAtIndex:0] ;
exporter.shouldOptimizeForNetworkUse = YES;
[exporter exportAsynchronouslyWithCompletionHandler:^{

}];

}
}
3.使用AVAssetResourceLoader回調下載,也是最終決定使用的技術

AVAssetResourceLoader通過你提供的委托對象去調節AVURLAsset所需要的加載資源。而很重要的一點是,AVAssetResourceLoader僅在AVURLAsset不知道如何去加載這個URL資源時才會被調用,就是說你提供的委托對象在AVURLAsset不知道如何加載資源時才會得到調用。所以我們又要通過一些方法來曲線解決這個問題,把我們目標視頻URL地址的scheme替換為系統不能識別的scheme,然后在我們調用網絡請求去處理這個URL時把scheme切換為原來的scheme。

實現邊下邊播功能AVResourceLoader的委托對象必須要實現AVAssetResourceLoaderDelegate下五個協議的其中兩個:

1//在系統不知道如何處理URLAsset資源時回調
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest NS_AVAILABLE(10_9, 6_0);
2//在取消加載資源后回調
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest NS_AVAILABLE(10_9, 7_0);

以下來說說具體要怎么做處理

第一步,創建一個AVURLAsset,并且用它來初始化一個AVPlayerItem
#define kCustomVideoScheme @"yourScheme"
NSURL *currentURL = [NSURL URLWithString:@"http://***.***.***"];
NSURLComponents *components = [[NSURLComponents alloc]initWithURL:currentURL resolvingAgainstBaseURL:NO];
1////注意,不加這一句不能執行到回調操作
components.scheme = kCustomVideoScheme;
AVURLAsset *urlAsset = [AVURLAsset URLAssetWithURL:components.URL  
options:nil];
2//_resourceManager在接下來講述
[urlAsset.resourceLoader setDelegate:_resourceManager queue:dispatch_get_main_queue()];
AVPlayerItem *item = [AVPlayerItem playerItemWithAsset:urlAsset];
_playerItem = item;

if (IOS9_OR_LATER) {
item.canUseNetworkResourcesForLiveStreamingWhilePaused = YES;
}
[self.avPlayer replaceCurrentItemWithPlayerItem:item];
self.playerLayer.player = self.avPlayer;
[self addObserverToPlayerItem:item];**
第二步,創建AVResourceManager實現AVResourceLoader協議
1 @interface AVAResourceLoaderManager : NSObject < AVAssetResourceLoaderDelegate >
第三步,實現兩個必須的回調協議,實現中有幾件需要做的事情
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest
{
1//獲取系統中不能處理的URL
NSURL *resourceURL = [loadingRequest.request URL];
2//判斷這個URL是否遵守URL規范和其是否是我們所設定的URL
if ([self checkIsLegalURL:resourceURL] && [resourceURL.scheme isEqualToString:kCustomVideoScheme]){
3//判斷當前的URL網絡請求是否已經被加載過了,如果緩存中里面有URL對應的網絡加載器(自己封裝,也可以直接使用NSURLRequest),則取出來添加請求,每一個URL對應一個網絡加載器,loader的實現接下來會說明
AVResourceLoaderForASI *loader = [self asiresourceLoaderForRequest:loadingRequest];
if (loader == nil){
loader = [[AVResourceLoaderForASI alloc] initWithResourceURL:resourceURL];
loader.delegate = self;
4//緩存網絡加載器
[self.resourceLoaders setObject:loader forKey:[self keyForResourceLoaderWithURL:resourceURL]];
}
5//加載器添加請求
[loader addRequest:loadingRequest];
6//返回YES則表明使用我們的代碼對AVAsset中請求網絡資源做處理
return YES;
}else{
return NO;
}

}
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest
{
//如果用戶在下載的過程中調用者取消了獲取視頻,則從緩存中取消這個請求
NSURL *resourceURL = [loadingRequest.request URL];
NSString *actualURLString = [self actualURLStringWithURL:resourceURL];
AVResourceLoaderForASI *loader = [_resourceLoaders objectForKey:actualURLString];
[loader removeRequest:loadingRequest];
}
第四步,判斷緩存中是否已下載完視頻
- (void)addRequest:(AVAssetResourceLoadingRequest *)loadingRequest
{
//1判斷自身是否已經取消加載
if(self.isCancelled==NO){
//2判斷本地中是否已經有文件的緩存,如果有,則直接從緩存中讀取數據,文件保存和讀取這里不做詳述,使用者可根據自身情況創建文件系統
AVAResourceFile *resourceFile = [self.resourceFileManager resourceFileWithURL:self.resourceURL];
if (resourceFile) {
//3若本地文件存在,則從文件中獲取以下屬性  
loadingRequest.contentInformationRequest.byteRangeAccessSupported = YES;
//3.1contentType
loadingRequest.contentInformationRequest.contentType = resourceFile.contentType;
//3.2數據長度                    
loadingRequest.contentInformationRequest.contentLength = resourceFile.contentLength;
//3.3請求的偏移量
long long requestedOffset = loadingRequest.dataRequest.requestedOffset;
//3.4請求總長度
NSInteger requestedLength = loadingRequest.dataRequest.requestedLength;
//3.5取出本地文件中從偏移量到請求長度的數據
NSData *subData = [resourceFile.data subdataWithRange:NSMakeRange(@(requestedOffset).unsignedIntegerValue, requestedLength)];
//3.6返回數據給請求
[loadingRequest.dataRequest respondWithData:subData];
[loadingRequest finishLoading];
}else{
//4如果沒有本地文件,則開啟網絡請求,從網絡中獲取 ,見第五步 
[self startWithRequest:loadingRequest];
}
}
else{
//5如果已經取消請求,并且請求沒有完成,則封裝錯誤給請求,可自己實現
if(loadingRequest.isFinished==NO){
[loadingRequest finishLoadingWithError:[self loaderCancelledError]];
}
}
}
第五步,添加loadingRequest到網絡文件加載器,這部分的操作比較長
- (void)startWithRequest:(AVAssetResourceLoadingRequest *)loadingRequest
{
1//判斷當前請求是否已經開啟,由于蘋果系統原因,會有兩次回調到AVResourceLoaderDelegate,我們對其進行判斷,只開啟一次請求
if (self.dataTask == nil){
2//根據loadingRequest中的URL創建NSURLRequest,注意在此把URL中的scheme修改為原先的scheme
NSURLRequest *request = [self requestWithLoadingRequest:loadingRequest];
__weak __typeof(self)weakSelf = self;
3//獲取url的絕對路徑,并使用ASIHttpRequest進行網絡請求,下面的請求方法經過封裝,就不詳說如何對ASI進行封裝了,但是每一步需要做的事情能以block的形式更好說明
NSString *urlString = request.URL.absoluteString;
self.dataTask = [self GET:urlString requestBlock:^(Request *req) {
NSLog(@"### %s %@ ###", __func__, req);
4//在接受到請求頭部信息時,說明鏈接成功,數據開始傳輸
if (req.recvingHeader//意思是請求接受到頭部信息狀態){
NSLog(@"### %s recvingHeader ###", __func__);
__strong __typeof(weakSelf)strongSelf = weakSelf;
if ([urlString isEqualToString:req.originalURL.absoluteString]) {
4.1//,創建臨時數據保存網絡下載下來的視頻信息
strongSelf.tempData = [NSMutableData data];
}
4.2//把頭部信息內容寫入到AVAssetResourceLoadingRequest,即loadingRequest中
[strongSelf processPendingRequests];
}
else if (req.recving//請求接受中狀態){
NSLog(@"### %s recving ###", __func__);
__strong __typeof(weakSelf)strongSelf = weakSelf;
5//此處需多次調用把請求的信息寫入到loadingRequest的步驟,實現下載的過程中數據能輸出到loadingRequest播放
if (urlString == req.originalURL.absoluteString) {
5.1//這個處理是判斷此時返回的頭部信息是重定向還是實際視頻的頭部信息,如果是重定向信息,則不作處理
if (!_contentInformation && req.responseHeaders) {
if ([req.responseHeaders objectForKey:@"Location"] ) {
NSLog(@" ### %s redirection URL ###", __func__);
}else{
//5.2如果不是重定向信息,則把需要用到的信息提取出來
_contentInformation = [[RLContentInformationForASI alloc]init];
long long numer = [[req.responseHeaders objectForKey:@"Content-Length"]longLongValue];
_contentInformation.contentLength = numer;
_contentInformation.byteRangeAccessSupported = YES;
_contentInformation.contentType = [req.responseHeaders objectForKey:@"Content-type"];
}
}

//5.3開始從請求中獲取返回數據
NSLog(@"### %s before tempData length = %lu ###", __FUNCTION__, (unsigned long)self.tempData.length);
strongSelf.tempData = [NSMutableData dataWithData:req.rawResponseData];
NSLog(@"### %s after tempData length = %lu ###",__FUNCTION__, (unsigned long)self.tempData.length);
//5.4把返回數據輸出到loadingRequest中
[strongSelf processPendingRequests];
}
}else if (req.succeed){
6//請求返回成功,在這里做最后一次把數據輸出到loadingRequest,且做一些成功后的事情
NSLog(@"### %s succeed ###", __func__);
NSLog(@"### %s tempData length = %lu ###", __func__, (unsigned long)self.tempData.length);
__strong __typeof(weakSelf)strongSelf = weakSelf;
if (strongSelf) {
[strongSelf processPendingRequests];

7//保存緩存文件,我在保存文件這里做了一次偷懶,如果有人參考我寫的文件可對保存文件作改進,在每次返回數據時把數據追加寫到文件,而不是下載成功之后才保存,這請求時也可以使用這個來實現斷點重輸的功能
AVAResourceFile *resourceFile = [[AVAResourceFile alloc]initWithContentType:strongSelf.contentInformation.contentType date:strongSelf.tempData];
[strongSelf.resourceFileManager saveResourceFile:resourceFile withURL:self.resourceURL];
8//在此做一些清理緩存、釋放對象和回調到上層的操作
[strongSelf complete];
if (strongSelf.delegate && [strongSelf.delegate respondsToSelector:@selector(resourceLoader:didLoadResource:)]) {
[strongSelf.delegate resourceLoader:strongSelf didLoadResource:strongSelf.resourceURL];
}
}
}else if (req.failed){
//9如果請求返回失敗,則向上層拋出錯誤,且清理緩存等操作
NSLog(@"### %s failed ###" , __func__);
[self completeWithError:req.error];
}
}];
}
[self.pendingRequests addObject:loadingRequest];
}
第六步,把請求返回數據輸出到loadingRequest的操作
- (void)processPendingRequests
{
__weak __typeof(self)weakSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
__strong __typeof(weakSelf)strongSelf = weakSelf;
NSMutableArray *requestsCompleted = [NSMutableArray array];
1//從緩存信息中找出當前正在請求中的loadingRequest
for (AVAssetResourceLoadingRequest *loadingRequest in strongSelf.pendingRequests){
2//把頭部信息輸出到loadingRequest中
[strongSelf fillInContentInformation:loadingRequest.contentInformationRequest];      
3//把視頻數據輸出到loadingRequest中
BOOL didRespondCompletely = [strongSelf respondWithDataForRequest:loadingRequest.dataRequest];
4//在success狀態中做最后一次調用的時候,檢測到請求已經完成,則從緩存信息中清除loadingRequest,并且把loadingRequest標志為完成處理狀態
if (didRespondCompletely){
[requestsCompleted addObject:loadingRequest];
[loadingRequest finishLoading];
}
}
5//清理緩存
[strongSelf.pendingRequests removeObjectsInArray:requestsCompleted];
});
}
、

//把提取出來的頭部信息輸出到loadingRequest中,可以優化
- (void)fillInContentInformation:(AVAssetResourceLoadingContentInformationRequest *)contentInformationRequest
{
if (contentInformationRequest == nil || self.contentInformation == nil){
return;
}
contentInformationRequest.byteRangeAccessSupported = self.contentInformation.byteRangeAccessSupported;
contentInformationRequest.contentType = self.contentInformation.contentType;
contentInformationRequest.contentLength = self.contentInformation.contentLength;
}

//把緩存數據輸出到loadingRequest中
- (BOOL)respondWithDataForRequest:(AVAssetResourceLoadingDataRequest *)dataRequest
{
long long startOffset = dataRequest.requestedOffset;
if (dataRequest.currentOffset != 0){
startOffset = dataRequest.currentOffset;
}

// Don't have any data at all for this request
if (self.tempData.length < startOffset){
return NO;
}

// This is the total data we have from startOffset to whatever has been downloaded so far
NSUInteger unreadBytes = self.tempData.length - (NSUInteger)startOffset;

// Respond with whatever is available if we can't satisfy the request fully yet
NSUInteger numberOfBytesToRespondWith = MIN((NSUInteger)dataRequest.requestedLength, unreadBytes);

[dataRequest respondWithData:[self.tempData subdataWithRange:NSMakeRange((NSUInteger)startOffset, numberOfBytesToRespondWith)]];

long long endOffset = startOffset + dataRequest.requestedLength;
BOOL didRespondFully = self.tempData.length >= endOffset;

return didRespondFully;
}

視頻邊下邊播的流程大致上已經描述完畢,本博文中沒有說到的代碼有錯誤處理方式、緩存文件的讀寫和保存格式、部分內存緩存使用說明、
參考鏈接:http://www.codeproject.com/Articles/875105/Audio-streaming-and-caching-in-iOS-usinghttp://www.cnblogs.com/kenshincui/p/4186022.html#mpMoviePlayerController
補充:在開發過程中遇到的一些坑在這里補充一下1.在iOS9后,AVPlayer的replaceCurrentItemWithPlayerItem方法在切換視頻時底層會調用信號量等待然后導致當前線程卡頓,如果在UITableViewCell中切換視頻播放使用這個方法,會導致當前線程凍結幾秒鐘。遇到這個坑還真不好在系統層面對它做什么,后來找到的解決方法是在每次需要切換視頻時,需重新創建AVPlayer和AVPlayerItem。2.iOS9后,AVFoundation框架還做了幾點修改,如果需要切換視頻播放的時間,或需要控制視頻從頭播放調用seekToDate方法,需要保持視頻的播放rate大于0才能修改,還有canUseNetworkResourcesForLiveStreamingWhilePaused這個屬性,在iOS9前默認為YES,之后默認為NO。3.AVPlayer的replaceCurrentItemWithPlayerItem方法正常是會引用住參數AVPlayerItem的,但在某些情況下導致視頻播放失敗,它會馬上釋放對這個對象的持有,假如你對AVPlayerItem的實例對象添加了監聽,但是自己沒有對item的計數進行管理,不知道什么時候釋放這個監聽,則會導致程序崩潰。4.為什么我選擇第三種方法實現邊下邊播,第一種方法需要程序引入LocalServer庫,需增加大量app包大小,且需要開啟本地服務,從性能方面考慮也是不合適。第二種方式存在的缺陷很多,一來只能播放網絡上返回格式contentType為public/mpeg4等視頻格式的url視頻地址,若保存下來之后,文件的格式也需要保存為.mp4或.mov等格式的本地文件才能從本地中讀取,三來使用AVMutableComposition對視頻進行重構后保存,經過檢驗會對視頻源數據產生變化,對于程序開發人員來說,需要保證各端存在的視頻數據一致。第三種邊下邊播的方法其實是對第二種方法的擴展,能夠解決上面所說的三種問題,可操控的自由度更高。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,106評論 6 542
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,441評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,211評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,736評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,475評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,834評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,829評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,009評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,559評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,306評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,516評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,038評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,728評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,132評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,443評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,249評論 3 399
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,484評論 2 379

推薦閱讀更多精彩內容