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 API在WKWebView中播放视频。

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

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

对于主要托管短视频和照片的Instagram和TikTok之类的服务,Telegram Crawler积极地在Telegram数据中心缓存媒体内容,并通过SystemVideoContentNode或NativeVideoContentNode将其作为本机视频提供。
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分享了如何构建功能强大的内容系统,以支持许多外部发行商,在即时通讯内提供流畅的阅读体验。它涉及复杂的产品思维和精心的工程工作,为竞争对手树立了高标准。

你可能感兴趣的:(Telegram-iOS 源码分析:第七部分(Link Preview and Instant View))