动手打造一款 canvas 排版引擎

Posted 网易云音乐技术团队

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了动手打造一款 canvas 排版引擎相关的知识,希望对你有一定的参考价值。

题图

图片来源:https://unsplash.com

服务,用 puppeteer 访问提前写好的网页来截图。

  • 直接使用 CanvasRenderingContext2D 的 api 或者使用辅助绘图的工具如 react-canvas 等来绘制。

  • 使用前端页面截图框架,比如 html2canvasdom2image,用 html 将页面结构写好,再在需要的时候调用框架 api 截图

  • 方案分析:

    1. 依赖服务端这种方案会消耗一定的服务端资源,尤其截图这种服务,对 cpu 以及带宽的消耗都是很大的,因此在一些可能高并发或者图片比较大的场景用这种方案体验会比较差,等待时间很长,这种方案的优点是还原度非常高,由于服务端无头浏览器版本是确定的,所以可以确保所见即所得,并且从开发上来说,无其他学习成本,如果业务还不是很大访问量不高用这种方案是最可靠的。

    2. 这种方案比较硬核,比较费时费力,大量的代码来计算布局的位置,文字是否换行等等,并且当开发完成后,如果 ui 后续有一些调整,又要在茫茫代码中寻找你要修改的那个它。这个方案的优点是细节很可控,理论上各种功能都可以完成,如果头发够用的话。

    3. 这应该也是目前 web 端使用最广的一种方案了,截止目前 html2canvas star 数量已经 25k。html2canvas 的原理简单来说就是遍历 dom 结构中的属性然后转化到 canvas 上来渲染出来,所以它必然是依赖宿主环境的,那么在一些老旧的浏览器上可能会遇到兼容性问题,当然如果是开发中就遇到了还好,毕竟我们是万能的前端开发(狗头),可以通过一些 hack 手段来规避,但是 c 端产品会运行在各种各样的设备上,很难避免发布后在其他用户设备上兼容问题,并且出了问题除非用户上报,一般难以监控到,并且在国内小程序用户量基数很大,这个方案也不能在小程序中使用。所以这个方案看似一片祥和,但是会有一些兼容的问题。

    在这几年不同的工作中,基本都遇到了需要分享图片的需求,虽然需求一般都不大频次不高,但是印象中每次做都不是很顺畅,上面几种方案也都试过了,多多少少都有一些问题。

    萌生想法:

    在一次需求评审中了解到在后续迭代有 ui 统一调整的规划,并且会涉及到几个分享图片的功能,当时的业务是涉及到小程序以及 h5 的。会后打开代码,看到了像山一样的分享图片代码,并且穿插着各种兼容胶水代码,如此庞大的代码只是为了生成一个小卡片的布局,如果是 html 布局,应该 100 行就能写完,当时就想着怎么来进行重构。

    鉴于开发时间还很充裕,我在想有没有其他更便捷、可靠、通用一点的解决方案,并且自己对这块也一直很感兴趣,秉持着学习的态度,于是萌生了自己写一个库的想法,经过考虑后我选择了 react-canvas 的实现思路,但是react-canvas依赖于React框架,为了保持通用性,我们本次开发的引擎不依赖特定web框架、不依赖 dom 的 api,能根据类似 css 的样式表来生成布局渲染,并且支持进阶功能可以进行交互。

    在梳理了要做的功能后,一个简易的 canvas 排版引擎浮现脑海。

    节点名,这里我们支持基本的元素,像view,image,text,scroll-view等,另外还支持自定义标签,通过全局componentapi 来注册一个新的组件,利于扩展。

    text节点也是由内容决定尺寸。

    梳理好这几种模式之后就可以开始遍历计算了,对于一个树我们有多种遍历模式。

    广度优先遍历:

    广度优先遍历

    深度优先遍历:

    深度优先遍历

    这里我们对上面几种情况分别做考虑:

    1. 因为是参考父节点所以需要从父到子遍历。
    2. 没有遍历顺序要求。
    3. 父节点需要等所有子节点计算完成后再进行计算,因此需要广度优先遍历,并且是从子到父。

    这里出现了一个问题,第 1 种和第 3 种所需遍历方式出现了冲突,但是回过头来看预处理部分正是从父到子的遍历,因此 1、2 部分计算尺寸的任务可以提前在预处理部分计算好,这样到达这一步的时候只需要计算第3部分,即根据子节点计算。

    Image等自身有内容的节点就需要继承后重写_measureLayout方法,Text在内部计算换行后的宽度与高度,Image则计算缩放后的尺寸。

    Image

    对于渲染单个节点来说,功能比较常规,渲染器基本功能是根据输入来绘制不同的图形、文字、图片,因此我们只需要实现这些 api 就可以了,然后将节点的样式通过这些 api 按顺序来渲染出来,这里又说到顺序了,那么渲染这一步我们应该按照什么顺序呢。这里给出答案深度优先遍历

    canvas 默认合成模式下,在同一位置绘制,后渲染的会覆盖在上面,也就是说后渲染的节点的z-index更大。(由于复杂度原因,目前没有实现像浏览器合成层的处理,暂时是不支持手动设置z-index的。)

    另外我们还需要考虑一种情况,如何去实现overflow:hidden效果呢,比如圆角,在 canvas 中超出的内容我们需要进行裁剪显示,但是仅仅对父节点裁剪是不符合需求的,在浏览器中父节点的裁剪效果是可以对子节点生效的。

    在 canvas 中一个完整的裁剪过程调用是这样的.

    // save ctx status
    ctx.save();

    // do clip
    ctx.clip();

    // do something like paint...

    // restore ctx status
    ctx.restore();
    //

    需要了解的是,CanvasRenderingContext2D中的状态以栈的数据结构保存,当我们多次执行save后,每执行一次restore就会恢复到最近的一次状态

    CanvasRenderingContext2D.save()

    也就是说只有在cliprestore这个过程内绘制的内容才会被裁减,因此如果要实现父节点裁剪对子节点也生效,我们不能在渲染一个节点后马上restore,需要等到内部子节点都渲染完后再调用。

    下面通过图片讲解

    渲染

    如图,数字是渲染顺序

  • 绘制节点 1,由于还有子节点,所以不能马上 restore
  • 绘制节点 2,还有子节点,绘制节点 3,节点 3 没有子节点,因此执行 restore
  • 绘制节点 4,没有子节点,执行 restore,注意啦,此时节点 2 内的节点都已经绘制完毕,因此需要再次执行 restore,恢复到节点 1 的绘制上下文
  • 绘制节点 5,没有子节点,执行 restore,此时节点 1 内都绘制完毕,再次执行 restore
  • 由于我们在预处理中已经实现了Fiber结构,并且知道节点所在父节点的位置,只需要在每个节点渲染完成后进行判断,需要调用多少次restore

    至此,经过漫长的 debug 以及重构,已经能正常将输入的节点渲染出来了,另外需要做的是增加对其他 css 属性的支持,此时内心已经是激动万分,但是看着控制台里输出的渲染节点,总觉得还能做点什么。

    对了!每个图形的模型都保存了,那是不是可以对这些模型进行修改以及交互呢,首先定一个小目标,实现事件系统。

    事件处理器

    canvas 中的图形并不能像 dom 元素那样响应事件,因此需要对 dom 事件进行代理,判断在 canvas 上发生事件的位置,再分发到对应的 canvas 图形节点。

    如果按照常规的事件总线设计思路,我们只需要将不同的事件保存在不同的List结构中,在触发的时候遍历判断点是否在节点区域,但是这种方案肯定不行,究其原因还是性能问题。

    在浏览器中,事件的触发分为捕获冒泡,也就是说要按照节点的层级从顶至下先执行捕获,触及到最深的节点后,再以相反的顺序执行冒泡过程,List结构无法满足,遍历这个数据结构的时间复杂度会很高,体现到用户体验上就是操作有延迟。

    经过一阵的头脑风暴后想到事件其实也可以保存在树结构中,将有事件监听的节点抽离出来组成一个新的树,可以称之为“事件树”,而不是保存在原节点树上。

    Event Tree

    如图,在 1、2、3 节点挂载 click 事件,会在事件处理器内生成另一个回调树结构,在回调时只需要对这个树进行遍历,并且可以进行剪枝优化,如果父节点没有触发,则这个父节点下的子元素都不需要遍历,提高性能表现。

    另外一个重点就是判定事件点是否在元素内,对于这个问题,已经有了许多成熟的算法,如射线法

    时间复杂度:O(n) 适用范围:任意多边形

    算法思想:以被测点 Q 为端点,向任意方向作射线(一般水平向右作射线),统计该射线与多边形的交点数。如果为奇数,Q 在多边形内;如果为偶数,Q 在多边形外。

    但是对于我们这个场景,除了圆角外都是矩形,而圆角处理起来会比较麻烦,因此初版都是使用矩形来进行判断,后续再作为优化点改进。

    按照这个思路就可以实现我们简易的事件处理器。

    class EventManager 
      // ...

      // 添加事件监听
      addEventListener(type, callback, element, isCapture) 
        // ...
        // 构造回调树
        this.addCallback(callback, element, tree, list, isCapture);
      

      // 事件触发
      _emit(e) 
        const tree = this[`$e.typeTree`];
        if (!tree) return;

        /**
         * 遍历树,检查是否回调
         * 如果父级没有被触发,则子级也不需要检查,跳到下个同级节点
         * 执行capture回调,将on回调添加到stack
         */

        const callbackList = [];
        let curArr = tree._getChildren();
        while (curArr.length) 
          walkArray(curArr, (node, callBreak, isEnd) => 
            if (
              node.element.isVisible() &&
              this.isPointInElement(e.relativeX, e.relativeY, node.element)
            ) 
              node.runCapture(e);
              callbackList.unshift(node);
              // 同级后面节点不需要执行了
              callBreak();
              curArr = node._getChildren();
             else if (isEnd) 
              // 到最后一个还是没监测到,结束
              curArr = [];
            
          );
        

        /**
         * 执行on回调,从子到父
         */

        for (let i = 0; i < callbackList.length; i++) 
          if (!e.currentTarget) e.currentTarget = callbackList[i].element;
          callbackList[i].runCallback(e);
          // 处理阻止冒泡逻辑
          if (e.cancelBubble) break;
        
      

      // ...

    事件处理器完成后,可以来实现一个scroll-view了,内部实现原理是用两个 view,外部固定宽高,内部可以撑开,外部通过事件处理器注册事件来控制渲染的transform值,需要注意的是,transform渲染后,子元素的位置就不在原来的位置了,所以如果在子元素挂载了事件会偏移,这里在scroll-view内部注册了相应的捕获事件,当事件传入scroll-view内部后,修改事件实例的相对位置,来纠正偏移。

    class ScrollView extends View 
      // ...

      constructor(options, children) 
        // ...
        // 内部再初始化一个scroll-view,高度自适应,外层宽高固定
        this._scrollView = new View(options, [this]);
        // ...
      

      // 为自己注册事件
      addEventListener() 
        // 注册捕获事件,修改事件的相对位置
        this.eventManager.EVENTS.forEach((eventName) => 
          this.eventManager.addEventListener(
            eventName,
            (e) => 
              if (direction.match("y")) 
                e.relativeY -= this.currentScrollY;
              
              if (direction.match("x")) 
                e.relativeX -= this.currentScrollX;
              
            ,
            this._scrollView,
            true
          );
        );

        // 处理滚动
        this.eventManager.addEventListener("mousewheel", (e) => 
          // do scroll...
        );

        // ...
      

    重排重绘

    除了生成静态布局功能外,框架也有重绘重排的过程,当修改了节点的属性后会触发,内部提供了setStyle,appendChild等 api 来修改样式或者结构,会根据属性值来确认是否需要重排,如修改width会触发重排后重绘,修改backgroundColor则只会触发重绘,比如 scroll-view 滚动时,只是改变了 transform 值,只会进行重绘。

    兼容性

    虽然框架本身不依赖 dom,直接基于CanvasRenderingContext2D进行绘制,但是一些场景下仍需要作兼容性处理,下面举几个例子。

  • 微信小程序平台绘制图片 api 与标准不同,因此在 image 组件判断了平台,如果是微信则调用微信特定 api 进行获取
  • 微信小程序平台设置字体粗细在 ios 真机上不生效,内部判断平台后,会将文字绘制两次,第二次在第一次基础上进行偏移,形成加粗效果。
  • 自定义渲染

    虽然框架本身已经支持大部分场景的布局,但是业务需求场景复杂多变,所以提供了自定义绘制的能力,即只进行布局,绘制方法交给开发者自行调用,提供更高的灵活性。

    engine.createElement((c) => 
      return c("view"
        render(ctx, canvas, target) 
          // 这里可以获取到ctx以及布局信息,开发者绘制自定义内容
        ,
      );
    );

    web 框架中使用

    虽然 api 本身相对简单,但是仍然需要写一些重复的代码,结构复杂的时候不便于阅读。

    当在现代 web 框架中使用时,可以采用相应的框架版本,比如 vue 版本,内部会将 vue 节点转换为 api 调用,使用起来会更易于阅读,但是需要注意,由于内部会有节点转换过程,相比直接使用会有性能损耗,在结构复杂时差异会较明显。

    <i-canvas :width="300" :height="600">
      <i-scroll-view :styles="height:600">
        <i-view>
          <i-image
            :src="imageSrc"
            :styles="styles.image"
            mode="aspectFill"
          >
    </i-image>
          <i-view :styles="styles.title">
            <i-text>Hello World</i-text>
          </i-view>
        </i-view>
      </i-scroll-view>
    </i-canvas>

    调试

    鉴于业务场景比较简单,框架目前提供的调试工具还比较基础,通过设置debug参数可以开启节点布局的调试,框架会将所有节点的布局绘制出来,如果需要查看单个节点的布局,需要通过挂载事件后打印到控制台进行调试。后续核心功能完善后会提供更全面的可视化调试工具。

    成果

    经过亲身体验,在一般页面的开发效率上,已经与写 html 不相上下,这里为了展示成果,我写了一个简单的组件库 demo 页。

    在canvas中开发组件库

    源码[3]

    组件库Demo[4]

    性能

    框架在经过几次重构后已经取得了不错的表现,性能表现如下

    性能测试

    已经做了的优化:

  • 遍历算法优化
  • 数据结构优化
  • scroll-view 重绘优化
  • scroll-view 重绘只渲染范围内的元素
  • scroll-view 可视范围外的元素不会渲染
  • 图片实例缓存,虽然有 http 缓存,但是对于同样的图片会产生多个实例,内部做了实例缓存
  • 待优化:

  • 可中断渲染,由于我们已经实现了类似Fiber结构,所以后续有需要加上这个特性也比较方便
  • 预处理器还需要增强,增强对于用户输入的样式与结构的兼容,增强健壮性
  • 总结

    从最初想实现一个简单的图片渲染功能,最后实现了一个简易的 canvas 排版引擎,虽然实现的 feature 有限并且还有不少细节与 bug 需要修复,但是已经具有基本的布局以及交互能力,其中还是踩了不少坑,重构了很多次,同时也不禁感叹浏览器排版引擎的强大。并且从中也体会到了算法与数据结构的魅力,良好的设计是性能高、维护性佳的基石,也获得不少乐趣。

    另外这种模式经过完善后个人觉得还是有不少想象力,除了简单的图片生成,还可以用于 h5 游戏的列表布局、海量数据的表格渲染等场景,另外后期还有一个想法,目前社区渲染这块已经有很多做的不错的库,所以想将布局以及计算换行、图片缩放等功能独立出来一个单独的工具库,通过集成其他库来进行渲染。

    本人表达能力有限,可能还是有很多细节没有得到澄清,也欢迎大家评论交流。

    感谢阅读

    参考资料
    [1]

    在线示例: https://codesandbox.io/s/demo-forked-k1p71?file=/main.js

    [2]

    Demo: https://gitjinfeiyang.github.io/easy-canvas/example/ui.html

    [3]

    源码: https://github.com/Gitjinfeiyang/easy-canvas

    [4]

    组件库Demo: https://gitjinfeiyang.github.io/easy-canvas/example/ui.html

    打造完美 Typora

    打造完美 Typora

    文章目录

    一、前言

    Typora是一款优秀但不完美的MarkDown编辑器。

    笔者无论是学习笔记的记录或者博客的书写都是用的这款软件,不得不说MarkDown为笔者省去了不少排版优化的时间,即使纯Text仍然能写出优雅的排版。不仅如此,Typora 还为MarkDown文档提供了Mermaid支持,Mermaid是一款用代码绘制各种图(流程图、甘特图、Journey图等)的工具,因为Mermaid现在仍然处于初期阶段,过于复杂的实体关系图或者流程图在MarkDown中显示得不是很好,笔者通常会使用Mermaid整理一些不是特别复杂的关系梳理。

    今天主要是想将笔者之前怎么一步步优化Typora样式、怎么配置图床、主题的选择等等以及过程中踩的坑记录下来。一方面可以为将来换电脑的时候能够快速同步,另一方面分享出来也可以让大家少踩点坑。

    闲话唠到这里,开始整活。

    二、样式优化

    2.1 主题选择

    这块没有什么好说的,笔者建议使用默认的github主题。

    至于原因,且听笔者娓娓道来:

    首先据笔者实验,Typora对于暗色主题支持的不是很好

    1. 在设置界面,切换暗色主题后下拉菜单啥也看不见(第三方的暗色主题)

      这是GitHub下的设置界面

      这是Cobalt主题下的设置界面

    2. 暗色主题Pandoc导出不了

      Pandoc是一款将MarkDown导出为Word、PDF等格式的Typora插件。当执行深色主题导出时,我们会获得以下提示:

    3. 所见非所得

      如果不使用默认主题,我们发布到GitHub或者博客上时会导致自己电脑上看到的和发布上去的不一致(比如README.md文件)。你看着挺漂亮的效果可能别人就看不到。

    4. 部分深色主题对Mermaid支持不是很好

      其实主题就是字体+CSS文件,通过设置界面打开主题文件夹我们能很轻易的看到这点。

      而有的作者在制作主题的时候没有兼顾到一些极端场景(比如笔者上面提到的设置界面或者Mermaid),造成Mermaid图显示不全或者设置界面看着特别难受。

    5. 深色主题貌似伤眼

      这个可能是民科哈,详情可以参考这篇文章

    2.2 更换字体

    笔者在本地换了一套字体,说实话有点花里胡哨,不喜欢的朋友可以跳过这一小节。

    笔者在字魂网上下载了7号字体并安装到了Windows(需要付费的哈,或者小伙伴可以自己想办法获取一套)。

    然后打开主题文件夹(设置>外观里有),创建一个名为base.user.css的文件在根目录并编辑如下内容

    :root 
      --mermaid-theme: night; /*or base, dark, forest, neutral, night */
      --mermaid-font-family: "zihun7hao-wennuantongzhiti", verdana, arial, sans-serif;
      --mermaid-sequence-numbers: on; /* or "on", see https://mermaid-js.github.io/mermaid/#/sequenceDiagram?id=sequencenumbers*/
      --mermaid-flowchart-curve: linear /* or "basis", see https://github.com/typora/typora-issues/issues/1632*/;
      --mermaid--gantt-left-padding: 75; /* see https://github.com/typora/typora-issues/issues/1665*/
    
    body 
        font-family: "zihun7hao-wennuantongzhiti", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
        color: rgb(51, 51, 51);
        line-height: 1.6;
    
    

    这里不光设置了字体,笔者还对Mermaid的样式进行了一些调整,然后重启Typora即可看到效果。

    2.3 文字排版

    这一节纯属笔者经验之谈,不一定是最佳实践。

    其实不必多说,看官翻翻笔者历来的文章就能看出笔者的排版习惯:

    笔者的排版比较偏向学院派(就是毕设写论文那种),或者是出版社投稿的那种风格。

    几乎篇幅不断的文章笔者都会加上目录“[TOC]”,这样方便笔者和看官快速索引。

    对于偏方法论一类的文章,笔者习惯性会在文末追加一个引用来记录自己查阅过什么资料,当然此文不在此范围之内,看官若是感兴趣可以看下我的Elastic讲义一文文末。

    三、功能优化

    3.1 配置Gitee图床

    这块笔者踩了不少坑,眼泪都要流下来了。

    首先科普下为什么要搞一套图床:

    笔者常常在本地写好的博客上传时会遇到CSDN转存图片失败,因为图片路径在笔者本地,而浏览器是运行在沙盒里的,不能直接上你电脑里读取图片。这时就需要将你本地图片路径转换为互联网可以访问的路径才可以。

    同理,你的笔记在多个电脑之间同步的时候也会因为图片路径不一致导致显示不出来。

    这时候,你就需要Typora自动上传本地图片至云端,并替换原先本地图片的uri。

    大概就是这个情况,接下来我们看下怎么配置。

    1. 首先要下载并安装Node.js然后重启电脑!

      Typora的图床上传依赖于PicGo,而PicGo自身没有Gitee图床,这就需要自己安装插件,而PicGo插件的安装依赖于Node.js,但是笔者之前安装的2.2.2-stable版本并不会提示安装Node.js(现已修复),导致搞了半天也安装不上。。。

    2. 安装PicGo(建议安装最新版,设置>图像里就可以下载),并安装Gitee插件

    3. 按照下图对PicGo进行初始化设置

      开机自启的作用不必多说,时间戳重命名是为了防止同名文件上传到Git时候报错。

    4. 创建Gitee图床仓库,像我这么选

    5. 生成Gitee Token(一定要记录下来,后面有用)

    6. 配置PicGo仓库信息

      这里有几点需要注意:

      1. Owner是用户名而非昵称,需要点自己头像到Profile里看(@后面那段)
      2. repo不用写全URL,只用写仓库名就行
      3. token就是上面让生成的那个
    7. 配置Typora

      插入图片那里选择上传,只对本地应用即可(网络图片没必要二次上传)

      下面PicGo路径选择安装路径下的exe就可以

      这里需要注意Typora语言一定要选中文!因为笔者使用的是英文系统,Typora默认语言也是英文,导致上传服务下拉框中没有PicGo(app)这个选项。因为PicGo是国产的,老外估计也很少用这个图床。

    8. 配置完成我们就可以粘贴一个本地图片到MarkDown进行验证

      可以看到图片url已经变成了Gitee仓库中的地址

    3.2 笔记同步

    Typora并不能像印象笔记或者OneNote一样自动在多个设备中同步你的笔记(我觉得Typora可以做个类似的云端,即使付费笔者也可以接受)。

    所以笔者是用了腾讯的工蜂,自建了一个代码仓库用于同步我的笔记,同时也做一个backup。

    同时自己也写了一个cmd脚本用于提交代码,看官可以在自己的仓库下创建一个.cmd文件然后send一个link到桌面

    git add . && git commit -m "push code automatically" && git push
    

    3.3 每日TODO List

    笔者习惯每天都做一个Todo List,记录下今天要做什么事情,以及过程中的一些收获。当遇到相似问题的时候能够及时索引到之前做的笔记来快速处理。

    大家可以感受下笔者五月某一天的工作记录:

    而每个Time-Table的文件是以月为单位归档的

    这样将来遇到同样问题的时候只要记住“我在几月好像碰到过这个问题”,即可快速索引。

    四、结语

    以上就是笔者在使用Typora的时候遇到的问题和一些解决方案,可能不是很好,还希望多多指正。

    希望未来Typora也能越做越好,也能有更多小伙伴加入MarkDown的阵营中来。

    以上是关于动手打造一款 canvas 排版引擎的主要内容,如果未能解决你的问题,请参考以下文章

    canvas射线检测

    如何打造一款极速数据湖分析引擎

    打造完美 Typora

    打造完美 Typora

    打造完美 Typora

    UGUI射线检测

    (c)2006-2024 SYSTEM All Rights Reserved IT常识