Telegram-iOS 源碼分析:第七部分(Link Preview and Instant View)

版權(quán)聲明
本文內(nèi)容均為搬運贰您,目的只為更方便的學(xué)習(xí)Telegram編碼思維拢操。

如需查閱原作者文章庐冯,附贈原文章機票

Telegram構(gòu)建了一組功能展父,使用戶長時間停留在應(yīng)用程序內(nèi)。本篇文章將闡述Telegram為什么需要這些功能以及如何有效地實現(xiàn)它們篮绿。

即時通訊中的內(nèi)容系統(tǒng)

在深入探討技術(shù)細(xì)節(jié)之前亲配,我們可以從即時通訊的角度考慮內(nèi)容系統(tǒng)的作用惶凝。盡管Telegram中沒有集中的新聞源苍鲜,為什么它如此重要?

如果我們只能為IM達成一個目標(biāo)洒疚,那么絕對是消息的可達性油湖。高可達性可以給用戶更多的信心领跛,他們發(fā)送的消息將被對方可靠地查看吠昭,這應(yīng)該是使他們喜歡即時通訊的最終原因怎诫。事實證明,內(nèi)容系統(tǒng)是提高用戶可訪問性的阻礙蹦误,因為即使沒有消息可查看肉津,用戶也會更頻繁地使用內(nèi)容平臺應(yīng)用程序妹沙,這最終有助于他們更快地查看新消息距糖。牵寺。

為了提供從第三方網(wǎng)站閱讀內(nèi)容的愉快體驗俩块,即時通訊產(chǎn)品需要一種獲取結(jié)構(gòu)化數(shù)據(jù)的機制。否則捎拯,就必須在瀏覽器插件中打開鏈接狸眼,由于頁面加載時間較長和非本機頁面呈現(xiàn),這會給用戶帶來支離破碎的體驗屡限。主流的即時通訊應(yīng)用程序采用不同的方法來改進它罩旋。外部分享是方法之一,它要求發(fā)布者自愿提供結(jié)構(gòu)化數(shù)據(jù):

  • 托管發(fā)布服務(wù)。通過遷移發(fā)行方使用托管的出版編輯界面趣苏,微信(WeChat)等大型信使應(yīng)用程序直接通過官方賬戶平臺從作者那里獲取結(jié)構(gòu)化數(shù)據(jù)檩淋。它是中國最大的內(nèi)容分發(fā)服務(wù)之一日戈,每天在應(yīng)用程序內(nèi)產(chǎn)生數(shù)十億的頁面瀏覽量。
  • 共享SDK供其他應(yīng)用程序使用弯屈,將鏈接發(fā)送到即時通訊中并手動填充所需的數(shù)據(jù)宴偿,例如標(biāo)題,圖片和說明活翩。同樣的穆趴,這是微信(WeChat)使用的策略空入,它節(jié)省了構(gòu)建一個通用的web爬蟲程序的工程工作量单料,這類爬蟲可以從網(wǎng)頁中提取結(jié)構(gòu)化數(shù)據(jù)白对。

只有當(dāng)你的產(chǎn)品在中國市場占據(jù)一定地位時沉颂,這種方法才有效钉蒲。在全球市場上這樣做是不現(xiàn)實的线梗。Telegram已應(yīng)用智能設(shè)計來構(gòu)建其當(dāng)前的內(nèi)容系統(tǒng):

  • 2015年4月發(fā)布的鏈接預(yù)覽可顯示大多數(shù)網(wǎng)站的豐富預(yù)覽氣泡蜻牢。Telegram為了從鏈接中提取內(nèi)容構(gòu)建了搜尋器。它類似于Facebook Crawler昌阿,它讀取HTML內(nèi)容中的開放圖標(biāo)記。搜尋器在Telegram數(shù)據(jù)中心上運行,并且不會將任何客戶端信息泄漏到第三方網(wǎng)站。
  • 同年贤重,添加了應(yīng)用內(nèi)媒體播放功能祭犯,可播放Youtube键畴,Vimeo和SoundCloud中的媒體,而無需在瀏覽器小部件中查看。隨后添加了更多受支持的媒體服務(wù)锋叨,例如Instagram薄湿,Twitch等。
  • Instant View于2016年推出,這是一種以零頁面加載時間從新聞服務(wù)中打開文章的優(yōu)雅方法。從工程的角度來看,它類似于2015年首次亮相的Facebook Instant Articles
  • Telegraph也與Instant View一同推出动知。它是一種用于在Telegram數(shù)據(jù)中心上托管格式豐富的文章發(fā)布工具奠滑。
  • Instant View平臺&競賽于2017年啟動弃甥。提供了在線模板編輯器和一些慷慨的獎項,以激勵用戶為更多網(wǎng)站貢獻模板。
  • Instant View 2.0于2018年年底交付蝉娜,支持RTL,表格纸镊,相關(guān)文章的塊等峰搪。

總而言之尽纽,鏈接預(yù)覽通過格式豐富的氣泡快速鏈接到用戶矫膨。應(yīng)用內(nèi)媒體播放使用戶可以在鏈接中享受核心媒體內(nèi)容,而無需離開聊天界面。Instant View原生地以零頁面加載時間呈現(xiàn)文章。Instant View平臺使用戶可以貢獻模板,以擴展對更多網(wǎng)站的支持。

鏈接預(yù)覽(Link Preview)

如上一篇關(guān)于Bubble的文章所述,ChatMessageItem可以包含許多類型的Media。其中一種實現(xiàn)是TelegramMediaWebpage,它可以對Web鏈接的數(shù)據(jù)進行建模。

final public class TelegramMediaWebpage : Postbox.Media, Equatable {
    public var id: Postbox.MediaId? { get }
    public let peerIds: [Postbox.PeerId]
    public let webpageId: Postbox.MediaId
    public let content: SyncCore.TelegramMediaWebpageContent
    ...
}

public enum TelegramMediaWebpageContent {
    case Pending(Int32, String?)
    case Loaded(TelegramMediaWebpageLoadedContent)
}

public final class TelegramMediaWebpageLoadedContent: PostboxCoding, Equatable {
    public let url: String
    public let displayUrl: String
    public let hash: Int32
    public let type: String?
    public let websiteName: String?
    public let title: String?
    public let text: String?
    public let embedUrl: String?
    public let embedType: String?
    public let embedSize: PixelDimensions?
    public let duration: Int?
    public let author: String?
    public let image: TelegramMediaImage?
    public let file: TelegramMediaFile?
    public let attributes: [TelegramMediaWebpageAttribute]
    public let instantPage: InstantPage?
}

ChatMessageWebpageBubbleContentNode 在消息Bubble中呈現(xiàn)鏈接預(yù)覽:

final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode {
    private var webPage: TelegramMediaWebpage?
    private let contentNode: ChatMessageAttachedContentNode
}

final class ChatMessageAttachedContentNode: ASDisplayNode {
    private let lineNode: ASImageNode
    private let textNode: TextNode
    private let inlineImageNode: TransformImageNode
    private var contentImageNode: ChatMessageInteractiveMediaNode?
    private var contentInstantVideoNode: ChatMessageInteractiveInstantVideoNode?
    private var contentFileNode: ChatMessageInteractiveFileNode?
    private var buttonNode: ChatMessageAttachedContentButtonNode?
    
    private let statusNode: ChatMessageDateAndStatusNode
    private var additionalImageBadgeNode: ChatMessageInteractiveMediaBadge?
    private var linkHighlightingNode: LinkHighlightingNode?
    
    private var message: Message?
    private var media: Media?
}

讓我們使用YouTube鏈接來說明發(fā)送它并呈現(xiàn)其預(yù)覽消息Bubble的過程。

part7-webpage.png

編寫消息時鸥鹉,客戶端檢測到輸入文本中存在鏈接灸异,會啟動RPCmessages.getWebPagePreview預(yù)覽數(shù)據(jù)褥傍。后端響應(yīng)MessageMedia.messageMediaWebPage恍风,其中包含鏈接預(yù)覽數(shù)據(jù):

public enum MessageMedia: TypeConstructorDescription {
    case messageMediaWebPage(webpage: Api.WebPage)
}

public enum WebPage: TypeConstructorDescription {
    case webPage(
      flags: Int32,          // 127
      id: Int64,             // 1503448449063263326
      url: String,           // https://www.youtube.com/watch?v=GEZhD3J89ZE
      displayUrl: String,    // youtube.com/watch?v=GEZhD3J89ZE
      hash: Int32,           // 0
      type: String?,         // video
      siteName: String?,     // YouTube
      title: String?,        // WWDC 2020 Special Event Keynote —  Apple
      description: String?,  // Apple WWDC 2020 kicked off with big announcement...
      photo: Api.Photo?,     // TelegramApi.Api.Photo.photo(flags: 0, id: 6020589086160562979, ...
      embedUrl: String?,     // https://www.youtube.com/embed/GEZhD3J89ZE
      embedType: String?,    // iframe
      embedWidth: Int32?,    // 1280
      embedHeight: Int32?,   // 720
      duration: Int32?,      // nil
      author: String?,       // nil
      document: Api.Document?,  // nil
      cachedPage: Api.Page?,    // nil
      attributes: [Api.WebPageAttribute]? // nil
    )
}

點擊發(fā)送按鈕后,將觸發(fā)RPCmessages.sendMessage 窜骄,并且客戶端正在等待來自后端的響應(yīng)邻遏。等待時虐骑,已發(fā)送的消息提示會添加到聊天提示列表中廷没。如果客戶端已經(jīng)收到的響應(yīng)messages.getWebPagePreview颠黎,則Bubble會渲染成為漂亮的預(yù)覽Bubble狭归。否則过椎,它只會先顯示一條純文本消息讼撒,然后等待發(fā)送結(jié)果中Updates.updates的預(yù)覽數(shù)據(jù)诬乞,然后再渲染逮栅。

點擊播放按鈕后,功能openChatMessageImpl將啟動氢架,并最終創(chuàng)建一個WebEmbedPlayerNode實例來播放YouTube視頻拓售。

應(yīng)用內(nèi)媒體播放(In-App Media Playback)

WebEmbedPlayerNode利用YouTube IFrame Player APIWKWebView中播放視頻。

  • 函數(shù)webEmbedType通過嘗試extractYoutubeVideoIdAndTimestamp從URL字符串中提取YouTube視頻ID來檢測嵌入內(nèi)容的類型。
  • WebEmbedPlayerNode通過YoutubeEmbedImplementation初始化矢门。
  • YoutubeEmbedImplementation從Bundle資源加載HTML模板Youtube.html宣旱,通過視頻ID生成頁面內(nèi)容燎字,然后使用https://youtube.com/基礎(chǔ)網(wǎng)址通過WKWebView加載它。
  • 注入了Bundle目錄下的JavaScript文件 YoutubeUserScrip.js日川,以從嵌入式Y(jié)ouTube播放器中隱藏水印控件
  • YoutubeEmbedImplementation 實現(xiàn)協(xié)議方法以通過JavaScript調(diào)用播放,暫停和尋找播放器李请。

類似的方法被應(yīng)用到提供的長視頻或直播流內(nèi)容的其他媒體服務(wù)恩急,如VimeoTwitch以及generic可以嵌入作為一個iframe的網(wǎng)站拉讯。

對于主要托管短視頻和照片的Instagram和TikTok之類的服務(wù)隧熙,Telegram Crawler積極地在Telegram數(shù)據(jù)中心緩存媒體內(nèi)容,并通過SystemVideoContentNodeNativeVideoContentNode將其作為本機視頻提供损谦。
Telegram已經(jīng)在自己的后端維護了大量的用戶交互數(shù)據(jù)和媒體內(nèi)容。

Instant View

part7-instantview.png

讓我們使用Telegram在Covid-19上的官方博客解釋Instant View的內(nèi)部結(jié)構(gòu)。輸入鏈接時,要求使用相同的RPCmessages.getWebPagePreview ,這一次粤策,響應(yīng)已為其字段設(shè)置了值cachedPage

public enum WebPage: TypeConstructorDescription {
    case webPage(
      flags: Int32,          // 1311
      id: Int64,             // 4108701751117811561
      url: String,           // https://telegram.org/blog/coronavirus
      displayUrl: String,    // telegram.org/blog/coronavirus
      hash: Int32,           // 702078769
      type: String?,         // photo
      siteName: String?,     // Telegram
      title: String?,        // Coronavirus News and Verified Channels
      description: String?,  // Channels are a tool for broadcasting your public messages...
      photo: Api.Photo?,     // TelegramApi.Api.Photo.photo(flags: 0, id: 5777291004297194213, ...
      embedUrl: String?,     // nil
      embedType: String?,    // nil
      embedWidth: Int32?,    // nil
      embedHeight: Int32?,   // nil
      duration: Int32?,      // nil
      author: String?,       // Telegram
      document: Api.Document?,  // nil
      cachedPage: Api.Page?,    // TelegramApi.Api.Page.page(...)
      attributes: [Api.WebPageAttribute]? // nil
    )
}

public enum Page: TypeConstructorDescription {
    case page(
      flags: Int32,             // 0
      url: String,              // https://telegram.org/blog/coronavirus
      blocks: [Api.PageBlock],  // [TelegramApi.Api.PageBlock] 37 values
      photos: [Api.Photo],      // [TelegramApi.Api.Photo] 5 values
      documents: [Api.Document],// [TelegramApi.Api.Document] 2 values
      views: Int32?             // nil
    )
}

// inside blocks
[
  PageBlock.pageBlockCover,
  PageBlock.pageBlockChannel,
  PageBlock.pageBlockTitle,
  PageBlock.pageBlockAuthorDate,
  PageBlock.pageBlockParagraph,
  ...
  PageBlock.pageBlockRelateArticles
]

Api.Page將鏈接的結(jié)構(gòu)化數(shù)據(jù)建模為PageBlock的列表彩扔。PageBlock定義了28種類型的blocks贾惦,它們要么是顯示unit,要么是blocks的容器。擁有容器類型可以呈現(xiàn)具有嵌套結(jié)構(gòu)的復(fù)雜頁面须板。

indirect public enum PageBlock: TypeConstructorDescription {
    case pageBlockUnsupported
    case pageBlockTitle(text: Api.RichText)
    case pageBlockSubtitle(text: Api.RichText)
    case pageBlockAuthorDate(author: Api.RichText, publishedDate: Int32)
    case pageBlockHeader(text: Api.RichText)
    case pageBlockSubheader(text: Api.RichText)
    case pageBlockParagraph(text: Api.RichText)
    case pageBlockPreformatted(text: Api.RichText, language: String)
    case pageBlockFooter(text: Api.RichText)
    case pageBlockDivider
    case pageBlockAnchor(name: String)
    case pageBlockBlockquote(text: Api.RichText, caption: Api.RichText)
    case pageBlockPullquote(text: Api.RichText, caption: Api.RichText)
    case pageBlockCover(cover: Api.PageBlock) // container
    case pageBlockChannel(channel: Api.Chat)
    case pageBlockKicker(text: Api.RichText)
    case pageBlockTable(flags: Int32, title: Api.RichText, rows: [Api.PageTableRow])
    case pageBlockPhoto(flags: Int32, photoId: Int64, caption: Api.PageCaption, url: String?, webpageId: Int64?)
    case pageBlockVideo(flags: Int32, videoId: Int64, caption: Api.PageCaption)
    case pageBlockAudio(audioId: Int64, caption: Api.PageCaption)
    case pageBlockEmbed(flags: Int32, url: String?, html: String?, posterPhotoId: Int64?, w: Int32?, h: Int32?, caption: Api.PageCaption) // container to embed a web view
    case pageBlockEmbedPost(url: String, webpageId: Int64, authorPhotoId: Int64, author: String, date: Int32, blocks: [Api.PageBlock], caption: Api.PageCaption) // container
    case pageBlockCollage(items: [Api.PageBlock], caption: Api.PageCaption) // container
    case pageBlockSlideshow(items: [Api.PageBlock], caption: Api.PageCaption) // container
    case pageBlockList(items: [Api.PageListItem]) // container
    case pageBlockOrderedList(items: [Api.PageListOrderedItem]) // container
    case pageBlockDetails(flags: Int32, blocks: [Api.PageBlock], title: Api.RichText) // container
    case pageBlockRelatedArticles(title: Api.RichText, articles: [Api.PageRelatedArticle])
    case pageBlockMap(geo: Api.GeoPoint, zoom: Int32, w: Int32, h: Int32, caption: Api.PageCaption)
}

InstantPageUI模塊包含Instant View的所有UI代碼文件。InstantPageController是核心控制器习瑰,它的content node InstantPageControllerNode通過函數(shù)updateLayout管理子node和布局绪颖。它枚舉頁面塊并為每個塊創(chuàng)建相應(yīng)的InstantPageItem類型。

private func updateLayout() {
    ...
    let currentLayout = instantPageLayoutForWebPage(webPage, ...)
}

func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, ...) -> InstantPageLayout {
    var items: [InstantPageItem] = []
    ...
    for block in pageBlocks {
        let blockLayout = layoutInstantPageBlock(webpage: webPage, rtl: rtl, block: block, ...)
        let blockItems = blockLayout.flattenedItemsWithOrigin(CGPoint(x: 0.0, y: contentSize.height + spacing))
        items.append(contentsOf: blockItems)
    }
    ...
}

func layoutInstantPageBlock(webpage: TelegramMediaWebpage, rtl: Bool, block: InstantPageBlock, ...) {
    ...
    switch block {
        case let .title(text):
            return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
        case let .authorDate(author: author, date: date):
            ...
    ...
}

final class InstantPageLayout {
    let origin: CGPoint
    let contentSize: CGSize
    let items: [InstantPageItem]
}

InstantPageController使用緩存的頁面數(shù)據(jù)立即顯示渲染結(jié)果甜奄。同時柠横,它還通過方法actualizedWebpage發(fā)送一個RPC messages.getWebPage來更新。因此课兄,布局函數(shù)updateLayout通常至少被調(diào)用兩次或更多次牍氛。

考慮到布局功能始終在主線程中運行,因此如果即時頁面具有大量內(nèi)容塊烟阐,它可能會阻塞UI搬俊。例如,從電子書站點中提取的包含1MB文本的段落會大大降低整個應(yīng)用程序的速度曲饱,而相同數(shù)量的文本可以通過WKWebView輕松處理悠抹。當(dāng)前版本的Instant View假定頁面通常很短。

題外話扩淀,微信過去經(jīng)常以移動網(wǎng)站的形式發(fā)布來自官方帳戶的文章楔敌。在2018年,客戶端開始獲取結(jié)構(gòu)化數(shù)據(jù)并在本地構(gòu)建HTML內(nèi)容驻谆,這還將提前緩存CSS和JavaScript文件卵凑。它以某種方式呈現(xiàn)了類似的Instant View體驗。

Instant View Platform

在搜索工程師和移動瀏覽器領(lǐng)域胜臊,將鏈接從原始HTML轉(zhuǎn)換為干凈的結(jié)構(gòu)化塊是一個棘手的工業(yè)問題勺卢。Telegram發(fā)明了自己的規(guī)則語言來對內(nèi)容提取過程進行建模。該語言非常復(fù)雜象对,支持變量黑忱,函數(shù),擴展的XPath等勒魔。您可以查看為Medium甫煞,Telegraph和Telegram Blog構(gòu)建的示例模板,以快速理解它冠绢。

為了鼓勵用戶為更多的網(wǎng)站做出貢獻并定義規(guī)則抚吠,Telegram建立了一個在線IDE,并舉辦了兩次競賽弟胀,總獎金為50萬美元楷力。它還使您可以自由地對所有用戶公開制作模板喊式,也可以將其私下保存在自己的網(wǎng)站上。

結(jié)論

Telegram分享了如何構(gòu)建功能強大的內(nèi)容系統(tǒng)萧朝,以支持許多外部發(fā)行商岔留,在即時通訊內(nèi)提供流暢的閱讀體驗。它涉及復(fù)雜的產(chǎn)品思維和精心的工程工作剪勿,為競爭對手樹立了高標(biāo)準(zhǔn)贸诚。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市厕吉,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌械念,老刑警劉巖头朱,帶你破解...
    沈念sama閱讀 206,968評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異龄减,居然都是意外死亡项钮,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評論 2 382
  • 文/潘曉璐 我一進店門希停,熙熙樓的掌柜王于貴愁眉苦臉地迎上來烁巫,“玉大人,你說我怎么就攤上這事宠能⊙窍叮” “怎么了?”我有些...
    開封第一講書人閱讀 153,220評論 0 344
  • 文/不壞的土叔 我叫張陵违崇,是天一觀的道長阿弃。 經(jīng)常有香客問我,道長羞延,這世上最難降的妖魔是什么渣淳? 我笑而不...
    開封第一講書人閱讀 55,416評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮伴箩,結(jié)果婚禮上入愧,老公的妹妹穿的比我還像新娘。我一直安慰自己嗤谚,他們只是感情好棺蛛,可當(dāng)我...
    茶點故事閱讀 64,425評論 5 374
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著呵恢,像睡著了一般鞠值。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上渗钉,一...
    開封第一講書人閱讀 49,144評論 1 285
  • 那天彤恶,我揣著相機與錄音钞钙,去河邊找鬼。 笑死声离,一個胖子當(dāng)著我的面吹牛芒炼,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播术徊,決...
    沈念sama閱讀 38,432評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼本刽,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了赠涮?” 一聲冷哼從身側(cè)響起子寓,我...
    開封第一講書人閱讀 37,088評論 0 261
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎笋除,沒想到半個月后斜友,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,586評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡垃它,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,028評論 2 325
  • 正文 我和宋清朗相戀三年鲜屏,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片国拇。...
    茶點故事閱讀 38,137評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡拙吉,死狀恐怖朴乖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤订歪,帶...
    沈念sama閱讀 33,783評論 4 324
  • 正文 年R本政府宣布拳喻,位于F島的核電站婚苹,受9級特大地震影響它抱,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜陕习,卻給世界環(huán)境...
    茶點故事閱讀 39,343評論 3 307
  • 文/蒙蒙 一霎褐、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧该镣,春花似錦冻璃、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至嫁审,卻和暖如春跋炕,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背律适。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評論 1 262
  • 我被黑心中介騙來泰國打工辐烂, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留遏插,地道東北人。 一個月前我還...
    沈念sama閱讀 45,595評論 2 355
  • 正文 我出身青樓纠修,卻偏偏與公主長得像胳嘲,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子扣草,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,901評論 2 345

推薦閱讀更多精彩內(nèi)容