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

版权声明
本文内容均为搬运,目的只为更方便的学习Telegram编码思维。

如需查阅原作者文章,附赠原文章机票

Telegram构建了一组功能,使用户长时间停留在应用程序内。本篇文章将阐述Telegram为什么需要这些功能以及如何有效地实现它们。

即时通讯中的内容系统

在深入探讨技术细节之前,我们可以从即时通讯的角度考虑内容系统的作用。尽管Telegram中没有集中的新闻源,为什么它如此重要?

如果我们只能为IM达成一个目标,那么绝对是消息的可达性。高可达性可以给用户更多的信心,他们发送的消息将被对方可靠地查看,这应该是使他们喜欢即时通讯的最终原因。事实证明,内容系统是提高用户可访问性的阻碍,因为即使没有消息可查看,用户也会更频繁地使用内容平台应用程序,这最终有助于他们更快地查看新消息。。

为了提供从第三方网站阅读内容的愉快体验,即时通讯产品需要一种获取结构化数据的机制。否则,就必须在浏览器插件中打开链接,由于页面加载时间较长和非本机页面呈现,这会给用户带来支离破碎的体验。主流的即时通讯应用程序采用不同的方法来改进它。外部分享是方法之一,它要求发布者自愿提供结构化数据:

  • 托管发布服务。通过迁移发行方使用托管的出版编辑界面,微信(WeChat)等大型信使应用程序直接通过官方账户平台从作者那里获取结构化数据。它是中国最大的内容分发服务之一,每天在应用程序内产生数十亿的页面浏览量。
  • 共享SDK供其他应用程序使用,将链接发送到即时通讯中并手动填充所需的数据,例如标题,图片和说明。同样的,这是微信(WeChat)使用的策略,它节省了构建一个通用的web爬虫程序的工程工作量,这类爬虫可以从网页中提取结构化数据。

只有当你的产品在中国市场占据一定地位时,这种方法才有效。在全球市场上这样做是不现实的。Telegram已应用智能设计来构建其当前的内容系统:

  • 2015年4月发布的链接预览可显示大多数网站的丰富预览气泡。Telegram为了从链接中提取内容构建了搜寻器。它类似于Facebook Crawler,它读取HTML内容中的开放图标记。搜寻器在Telegram数据中心上运行,并且不会将任何客户端信息泄漏到第三方网站。
  • 同年,添加了应用内媒体播放功能,可播放Youtube,Vimeo和SoundCloud中的媒体,而无需在浏览器小部件中查看。随后添加了更多受支持的媒体服务,例如Instagram,Twitch等。
  • Instant View于2016年推出,这是一种以零页面加载时间从新闻服务中打开文章的优雅方法。从工程的角度来看,它类似于2015年首次亮相的Facebook Instant Articles
  • Telegraph也与Instant View一同推出。它是一种用于在Telegram数据中心上托管格式丰富的文章发布工具。
  • Instant View平台&竞赛于2017年启动。提供了在线模板编辑器和一些慷慨的奖项,以激励用户为更多网站贡献模板。
  • Instant View 2.0于2018年年底交付,支持RTL,表格,相关文章的块等。

总而言之,链接预览通过格式丰富的气泡快速链接到用户。应用内媒体播放使用户可以在链接中享受核心媒体内容,而无需离开聊天界面。Instant View原生地以零页面加载时间呈现文章。Instant View平台使用户可以贡献模板,以扩展对更多网站的支持。

链接预览(Link Preview)

如上一篇关于Bubble的文章所述,ChatMessageItem可以包含许多类型的Media。其中一种实现是TelegramMediaWebpage,它可以对Web链接的数据进行建模。

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中呈现链接预览:

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链接来说明发送它并呈现其预览消息Bubble的过程。

part7-webpage.png

编写消息时,客户端检测到输入文本中存在链接,会启动RPCmessages.getWebPagePreview预览数据。后端响应MessageMedia.messageMediaWebPage,其中包含链接预览数据:

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
    )
}

点击发送按钮后,将触发RPCmessages.sendMessage ,并且客户端正在等待来自后端的响应。等待时,已发送的消息提示会添加到聊天提示列表中。如果客户端已经收到的响应messages.getWebPagePreview,则Bubble会渲染成为漂亮的预览Bubble。否则,它只会先显示一条纯文本消息,然后等待发送结果中Updates.updates的预览数据,然后再渲染。

点击播放按钮后,功能openChatMessageImpl将启动,并最终创建一个WebEmbedPlayerNode实例来播放YouTube视频。

应用内媒体播放(In-App Media Playback)

WebEmbedPlayerNode利用YouTube IFrame Player APIWKWebView中播放视频。

  • 函数webEmbedType通过尝试extractYoutubeVideoIdAndTimestamp从URL字符串中提取YouTube视频ID来检测嵌入内容的类型。
  • WebEmbedPlayerNode通过YoutubeEmbedImplementation初始化。
  • YoutubeEmbedImplementation从Bundle资源加载HTML模板Youtube.html,通过视频ID生成页面内容,然后使用https://youtube.com/基础网址通过WKWebView加载它。
  • 注入了Bundle目录下的JavaScript文件 YoutubeUserScrip.js,以从嵌入式YouTube播放器中隐藏水印控件
  • YoutubeEmbedImplementation 实现协议方法以通过JavaScript调用播放,暂停和寻找播放器。

类似的方法被应用到提供的长视频或直播流内容的其他媒体服务,如VimeoTwitch以及generic可以嵌入作为一个iframe的网站。

对于主要托管短视频和照片的Instagram和TikTok之类的服务,Telegram Crawler积极地在Telegram数据中心缓存媒体内容,并通过SystemVideoContentNodeNativeVideoContentNode将其作为本机视频提供。
Telegram已经在自己的后端维护了大量的用户交互数据和媒体内容。

Instant View

part7-instantview.png

让我们使用Telegram在Covid-19上的官方博客解释Instant View的内部结构。输入链接时,要求使用相同的RPCmessages.getWebPagePreview ,这一次,响应已为其字段设置了值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将链接的结构化数据建模为PageBlock的列表。PageBlock定义了28种类型的blocks,它们要么是显示unit,要么是blocks的容器。拥有容器类型可以呈现具有嵌套结构的复杂页面。

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通过函数updateLayout管理子node和布局。它枚举页面块并为每个块创建相应的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使用缓存的页面数据立即显示渲染结果。同时,它还通过方法actualizedWebpage发送一个RPC messages.getWebPage来更新。因此,布局函数updateLayout通常至少被调用两次或更多次。

考虑到布局功能始终在主线程中运行,因此如果即时页面具有大量内容块,它可能会阻塞UI。例如,从电子书站点中提取的包含1MB文本的段落会大大降低整个应用程序的速度,而相同数量的文本可以通过WKWebView轻松处理。当前版本的Instant View假定页面通常很短。

题外话,微信过去经常以移动网站的形式发布来自官方帐户的文章。在2018年,客户端开始获取结构化数据并在本地构建HTML内容,这还将提前缓存CSS和JavaScript文件。它以某种方式呈现了类似的Instant View体验。

Instant View Platform

在搜索工程师和移动浏览器领域,将链接从原始HTML转换为干净的结构化块是一个棘手的工业问题。Telegram发明了自己的规则语言来对内容提取过程进行建模。该语言非常复杂,支持变量,函数,扩展的XPath等。您可以查看为Medium,Telegraph和Telegram Blog构建的示例模板,以快速理解它。

为了鼓励用户为更多的网站做出贡献并定义规则,Telegram建立了一个在线IDE,并举办了两次竞赛,总奖金为50万美元。它还使您可以自由地对所有用户公开制作模板,也可以将其私下保存在自己的网站上。

结论

Telegram分享了如何构建功能强大的内容系统,以支持许多外部发行商,在即时通讯内提供流畅的阅读体验。它涉及复杂的产品思维和精心的工程工作,为竞争对手树立了高标准。

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

推荐阅读更多精彩内容