欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Weex Android Border绘制

程序员文章站 2024-03-16 11:11:52
...

Weex Android Border绘制

在Android/iOS/H5开发中,为View/Component添加border是一个常见的需求。然而Android SDK并没有直接提供border的支持,需要开发者自行实现。本文首先阐述border问题的定义以及几种棘手的case,随后探讨目前Android平台上解决border问题的方案及其优劣,之后详细阐述Weex的border解决方案,并总结此问题。

背景

在Weex中,border实际上代表了四个属性,即border-width, border-color, border-style, border-radius,实际上Weex并不支持border这个shorthand。这四个属性的默认值和所支持的值与W3C略有不同:

  • border-width:默认值为0,支持设置为大于零的长度(单位是像素)。border-widthborder-top-width, border-right-width, border-bottom-widthborder-left-width这四个属性的缩写。
  • border-color: 默认值为black,支持值为某个颜色值。border-colorborder-top-color, border-right-color, border-bottom-color, border-left-color这四个属性的缩写。
  • border-style: 默认值为solid,支持值为soliddotteddashedborder-styleborder-top-style, border-right-style, border-bottom-style, border-left-style的缩写。

    • border-style不支持none,这与W3C标准不同,但却避免了border-style:none时,需要将border-width置为0的问题。
    • border-style:dotted会绘制长宽和border-width相等的正方形,而不是W3C中规定的圆。
  • border-radius: 默认值为0,支持设置为大于0的长度(单位是像素)。此属性指示user-agent在某个角落(左上,右上,右下,左下),使用指定的半径画一段圆心角为90度的椭圆弧。border-radiusborder-top-left-radius, border-top-right-radius, border-bottom-right-radius, border-bottom-left-radius这四个属性的缩写。

在根据以上属性绘制border时,需要考虑以下几个case的处理方法。

Corner Shaping

在W3C中,当border-radius大于0时,会使用一段椭圆弧来链接邻接的两条边。这段弧的宽度是由border-width决定的,且邻接两边的border-width可能不一致,因此inner-corner和outer-corner可能只能绘制出一条来。

当border-x-y-radius <= border-x-width或border-x-y-radius <= border-y-width时

代码片段:

<img src="http://www.fresher.ru/manager_content/images2/kadry-veka/big/2-1.jpg" 
          style="width:200px; height:300px; background-color:#0000ff;margin:50px;
          box-sizing:border-box;
          padding: 20px;
          border-color:#000000;
          border-style:solid;
          border-top-width: 30px;
          border-left-width: 50px;
          border-top-left-radius: 20px"/>

渲染结果如下所示:

Weex Android Border绘制

上述代码对左上角设置了border-radius,且border-radius小于邻接两边中某一边的border-width,此时只绘制outer-corner,不绘制inner-corner,即outer-corner表现为圆角,inner-corner表现为直角,且左上角圆弧对应的椭圆圆心在黑色的border区域内。

当border-x-y-radius > border-x-width 且 border-x-y-radius > border-y-width时

代码片段:

<img src="http://www.fresher.ru/manager_content/images2/kadry-veka/big/2-1.jpg" 
          style="width:200px; height:300px; background-color:#0000ff;margin:50px;
          box-sizing:border-box;
          padding: 20px;
          border-color:#000000;
          border-style:solid;
          border-top-width: 30px;
          border-left-width: 50px;
          border-top-left-radius: 80px"/>

渲染结果如下所示:

Weex Android Border绘制

上述代码对左上角设置了border-radius,且border-radius大于邻接两边任意一边的border-width,此时同时绘制outer-corner和inner-corner,即inner-corner和outer-corner均表现为圆角,且左上角圆弧对应的椭圆圆心在黑色border区域外。

Corner Clipping

Weex暂不支持设置background-clip属性,因此应用默认值,即background-clip:border-box。如果设置了border-radius,就要求border-box裁剪到指定的圆角矩形(可能退化成椭圆)区域,否则裁剪到一个矩形区域。同时CSS Backgrounds and Borders Module Level 3要求background-color需要延伸到border区域下面。因此对应于下面的代码片段

<img src="http://www.fresher.ru/manager_content/images2/kadry-veka/big/2-1.jpg" 
          style="width:300px; height:300px; background-color:#0000ff;margin:50px;
          box-sizing:border-box;
          padding: 20px;
          border-color:#000000;
          border-style:dashed;
          border-width: 30px;
          border-radius: 150px"/>

需要得到如下渲染效果:

Weex Android Border绘制

其中,border-style是dotted,所以可以在黑色的border下面的蓝色的背景。由于设置了padding,因此在使用蓝色背景填充padding区域,表现为蓝色圆环。border,padding,content区域均按照background-clip:border-box进行clip。

Overlapping-Curvers

由于在border-radius没有最大值,因此两个相邻的border-radius的和可能大于css box model的width,导致两条弧有重合的部分。在遇到这种情况时,userAgent必须根据下面的算法border-radius进行缩放:

Let f = min(Li/Si), where i ∈ {top, right, bottom, left}, Si is the sum of the two corresponding radii of the corners on side i, and Ltop = Lbottom = the width of the box, and Lleft = Lright = the height of the box. If f < 1, then all corner radii are reduced by multiplying them by f.

即对于如下的代码片段:

<img src="http://www.fresher.ru/manager_content/images2/kadry-veka/big/2-1.jpg" 
          style="width:200px; height:300px; background-color:#0000ff;margin:50px;
          box-sizing:border-box;
          padding: 20px;
          border-color:#000000;
          border-style:solid;
          border-top-width: 30px;
          border-left-width: 50px;
          border-right-width: 30px;
          border-top-left-radius: 180px;
          border-top-right-radius: 220px"/>

需要如下的渲染效果:

Weex Android Border绘制

其中左上角和右上角的border-radius相加已经超过width,此时按照上述算法,对所有的border-radius按照系数0.5进行缩放,并使用缩小后的border-radius进行渲染。

现状

目前Android SDK(截至至Android 7.0, API 24, Nougat) 不支持border,想要绘制border,需要开发者使用一些hack方案。由于Android支持为View设置一个背景图层(Drawable),常见的android border绘制方案均是使用背景图层绘制border,下面列举主流的border绘制方案及其优劣。

9-Patch

9-Patch是一种Android下的一种图片格式,可以为这种图片定义可缩放区和不可缩放区,以避免图片缩放带来的模糊问题。通过9-Patch,把border的直线区域定义成可缩放区域,圆角区域定位为不可缩放区域,来绘制border,同时解决图片缩放带来的模糊问题。

优点

  • 简单,使用可视化工具处理图片,无需代码。

Weex Android Border绘制

缺点

  • 扩展性极差,例如需要为每一个不同的border-colorborder-style提供一张图片。

因此9-Patch方案适用于简单的border样式,且样式在编译时就已经确定了情况。

<shape>

<shape>描述一个基本图形,例如矩形、圆形、圆角矩形等。<shape>可以使用XML或Java代码定义,二者基本等价。鉴于使用XML可读性较高,下面使用XML定义一个<shape>。

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" >
    <!-- border-radius -->
    <corners android:radius="20px" />

    <!-- background-color -->
    <solid android:color="#0000FF" />

    <!-- border-width & border-color -->
    <stroke
        android:width="30px"
        android:color="#000000" />
</shape>

可得到如下的渲染效果

Weex Android Border绘制

优点

  • 使用XML编写,逻辑简单,可读性强
  • 具有一定的扩展性,可以通过Java代码操作ShapeDrawable的属性。

缺点

<shape>不支持border-x-style, border-x-color, border-x-width。常用的hack办法是通过<layer-list>,在的上面<shape>遮盖一层,以实现单边的效果。下图通过遮盖的方法,隐藏了bottom这条边,保持left,top,right三条边的显示:

Weex Android Border绘制

这样做有如下几个缺陷:

  • view中几乎每一个像素都绘制了两次,带来了OverDraw问题,会导致一定的性能损失。
  • <layer-list>只能解决某条边显示或不显示的问题,无法解决border-left-style:dotted,border-top-style:dashed这样两边style或color不一致的问题。

适用于复杂度中等的case,且border的各边颜色和style一致的情况。

Canvas

自定义一个Drawable对象,根据border-style, border-radius, border-width, border-color,使用Canvas绘制每一条边。

优点

  • 扩展性强,可以解决上述几个方案中无法支持的case。

缺点

  • 逻辑复杂,每一条边,每一个角都需要单独处理。

适用于复杂度高,且对性能有要求的情景。

解决方案

Weex Android采用自定义Drawable对象,并通过Canvas来绘制border。

Weex Android在绘制border的时候,依照border的四条边,分别绘制各边。对于每一条边,需要绘制三次,分别是previous-corner,line, post-corner。具体流程如下图所示:
Weex Android Border绘制

概念阐述

下面先解释Weex Android绘制border过程中用到的一些名词。

Path

在Android中,Path描述一个闭合的contour或者一条开放的线。例如,可以用Path描述一个椭圆,一条直线,一条Bézier曲线等几何图形。Weex使用Path描述圆角矩形的corner.

PaintStyle

在Android中Paint.Style描述绘制模式。常见的绘制模式有两种,Stroke及Fill。

Fill

Fill 使用指定的颜色填充指定区域,如使用蓝色填充矩形区域。

Weex Android Border绘制

Stroke

Stroke 使用指定的颜色和宽度,对指定的区域进行描边。例如使用蓝色画笔,画笔宽度为5像素,对矩形进行描边。

Weex Android Border绘制

处理border-radius

在绘制border前,需要依照Overlapping-Curvers中所述的算法,对border-radius进行缩放。之后的绘制,使用缩放后的border-radius

绘制background-color

在绘制background-color的时候,根据border-radius, border-width, width, height构造了一个圆角矩形Path,并使用background-color填充这个Path。

绘制border

下面以绘制Top Line为例,阐述绘制一边的方法。绘制其他边的过程与此类似,不再重复论述。

只有当border-width大于0的时候,某条边才会被绘制。而根据border-x-width, border-y-width, border-x-x-radius的大小关系,绘制某条边的时候可能出现下面三种case。

下面图中W1,W2是邻接两边的border-width,R是border-x-y-radius,实线为Paint笔触所走路线,虚线表示辅助线。黄色区域为border-width所影响的填充区域,蓝色区域为邻接两边的过渡区域(若有)。

border-x-y-radius = 0

此时不存在border-corner,top-line与left-line使用直线连接,转折处呈现直角,如下图所示。

Weex Android Border绘制

border-x-y-radius > 0

此时存在border-corner,top-line与left-line使用椭圆弧连接,椭圆的圆心取决与border-width与border-radius的大小关系。连接top-line与left-line的椭圆弧的圆心角为90℃,top-line负责绘制其中的45℃,left-line负责绘制剩余的45度,下面将阐述top-line负责绘制的45℃椭圆弧,left-line绘制剩余45℃椭圆弧的过程与此类似。

border-x-y-radius <= border-x-width || border-x-y-radius <= border-y-radius

左上角border-corner所对应的椭圆弧的圆心在border的填充区域内,椭圆的长轴和短轴长度相等,椭圆退化成圆,圆的半径是border-x-y-radius/2,绘制圆弧所使用的笔触的宽度也是border-x-y-radius/2

Weex Android Border绘制

border-x-y-radius > border-x-width && border-x-y-radius > border-y-radius

左上角border-corner所对应的椭圆弧的圆心在border填充区域外,椭圆的一个半轴长为R-W1/2,另一个半轴的长为R-W2/2,绘制圆弧所使用的笔触的宽度为W2.

Weex Android Border绘制

To be excellent...

按照上述绘制流程,可以完成依照border-width, border-color, border-style, border-radius属性来绘制border的渲染任务。然而为了更好的渲染效果,还需要考虑以下问题:

处理background-clip:border-box属性

background-clip属性描述了background区域和border区域之间的关系。在weex中,默认指定了background-clip: border-box,且该属性不可修改。故有如下关系:

  • background-color区域的z-index(假设能为其设置z-index)最小,渲染区域包括border区,content区。
  • content区域的z-index大于background-color区域,border区域的z-index大于content区域,border与content渲染区域没有重合,二者之间的距离是padding。
  • 如果存在border-radius,background-color区域和content区域可能表现为圆角矩形或椭圆。
  • 如果存在半透明效果,透过content或者border,可以看见background-color区域。

下面所示是带有border-radius,padding,background-color属性的view的绘制结果

Weex Android Border绘制

旋60℃后,可以看到background-color, content, border的层次关系。

Weex Android Border绘制

为了实现上述的效果,下面以<image>为例,阐述几种常见的实现background-clip:border-box属性的方法。需要绘制的view对应的weex描述为

    <image src="http://www.fresher.ru/manager_content/images2/kadry-veka/big/2-1.jpg" 
          style="width:300px; height:300px; background-color:#0000ff;margin:50px;
          box-sizing:border-box;
          padding: 20px;
          border-color:#000000;
          border-radius: 150px"></image>

Clip-Path

Android存在Canvas.clipPath()方法,可以把指定的Canvas裁剪成指定的形状。

因此可以先按照上面所述的z轴关系,依次绘制各个区域,绘制的图形均为矩形,矩形的面积为background-color区域的外接矩形。之后利用Canvas.clipPath(),裁剪各个区域,得到上述所示的图形。

这个解决方案相对简单,但是存在下面三个缺陷:

  • 对于background-color外接矩形上的每一个将被裁剪的像素,均被background-color,content绘制了两次,存在OverDraw问题,性能可能会有一定程度下降。
  • 对于API 18以下的android系统,Canvas.clipPath()与硬件加速不兼容,需要关闭硬件加速。
  • Android的anti-aliasing机制与paint强绑定,由于Canvas.clipPath()与paint无关,所以不支持anti-aliasing,可能会导致锯齿的出现。

下图是在API 19的Android手机上,渲染的效果,可以看到,content与padding区域的交接处,出现了明显的spatial-aliasing。

Weex Android Border绘制

Porter-Duff

Porter-Duff是一种alpha-composing的方式,也可以视为blend-mode问题的一个子集。Porter-Duff描述了在GPU渲染时,同时存在区域a和区域b,把区域a和区域b叠加在一起的方式。使用porter-duff,可以实现把区域a和区域b按照z-index叠加或区域a作为区域b的蒙版等效果。

例如对于如下两张图片

Weex Android Border绘制, Weex Android Border绘制

使用不同的porter-duff叠加方式,可以得到如下叠加结果结果

Weex Android Border绘制

可以使用Paint.setXfermode(PorterDuffXfermode)来指定porter-duff叠加方式。因此对于image的background-clip:border-box问题,可以先画一个content区域的path,并使用任意颜色填充,之后使用PorterDuffMode.SRC_IN(即上图中的IN)叠加模式绘制image。伪代码如下:

function onDraw(canvas)
  //PorterDuff叠加的是两个bitmap,因此需要保存目前canvas已有的信息,并创建一个新的图层。
  layer=canvas.saveLayer(0,0,getWidth(),getHeight(),null,Canvas.ALL_SAVE_FLAG);
  paint.setColor(Color.BLUE);
  Path path=getContentPath();
  //先在新图层上绘制蒙版
  canvas.drawPath(path,paint);
  
  //下面绘制真正的image,首先设置PorterDuffMode.SRC_IN
  paint.setXfermode(SRC_IN);
  //将待绘制的image绘制到一个名为extra的Canvas上
  super.onDraw(extra);
  //每个Canvas都对应于一个bitmap,把extra Canvas对应的bitmap取出,绘制到当前的canvas上
  canvas.drawBitmap(getCanvasBitmap(bitmap),0,0,paint);
  paint.setXfermode(null);
  //恢复正常图层
  canvas.restoreToCount(layer);

Porter-Duff可以解决spatial-aliasing的问题,但依然存在下面两个问题

  • Overdraw问题依然没有解决
  • 需要额外创建一个bitmap来保存蒙版信息,会造成额外的内存消耗。对于这个bitmap的每一个像素,如果使用RGB_565方式存储,需要2个字节,因此对于一个720*1024的view,需要额外2*720*1024=1440kb额外内存。通常view会存在叠加关系,这导致所需额外内存可能会很多。

使用Porter-Duff方法,得到如下绘制效果,可以看到spatial-aliasing少了很多。

Weex Android Border绘制

Shader

在Android中,Path描述一个闭合的contour或者一条开放的线,而Shader描述对这个contour着色的方式。故可以先计算content区域对应的Path,再把图片作为Shader,填充Path区域。

这种方法可以解决OverDraw,spatial-aliasing和低版本API兼容问题,且不带来额外的内存消耗。Shader方案的伪代码如下:

  function onDraw()
      bounds = getBounds();
      path = borderDrawable.getContentPath();
      //由于bitmap的大小可能和view的小不一致,因此需要进行缩放。
      matrix.setScale(bounds.width() / bitmap.getWidth(),
                      bounds.height() / bitmap.getHeight());
      BitmapShader bitmapShader = new BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
      bitmapShader.setLocalMatrix(matrix);
      mPaint.setStyle(Paint.Style.FILL);
      mPaint.setShader(bitmapShader);
      canvas.drawPath(path, mPaint);

得到如下渲染效果

Weex Android Border绘制

然而Shader方式的局限性较强,只有当Shader可以映射到Bitmap的时候,才可以使用这种方案。对于一个<text>,无法直接得到其对应的bitmap方式,也就无法使用Shader方案。

对比shader和Porte-Duff方案,有如下结论:

  • Shader方案的通用性较差,只能针对bitmap元素,但是没有额外的内存消耗。当<image>能映射到bitmap的时候,可用shader方案处理background-clip:border-box属性。
  • Porter-Duff方案通用性较强,但需要额外的内存。当某一个tag无法映射到bitmap的时候,使用Porter-Duff方案。

Path Too Large To Be Rendered To a Texture

当使用Path描述border的一条边的时候,如果某一条边长度超过一个值,OpenGL ES就会拒绝这条边对应的渲染指令,并抛出Path Too Large To Be Rendered To a Texture异常。

这是由于在Android中,Path是使用CPU绘制,且硬件加速默认处于开启状态。这个时候,CPU将先把Path绘制到bitmap上,之后把bitmap上传到GPU的Texture存储区域中。由于Texture的存储空间有限,如果Path过长,会导致Texture溢出,后果就是这条渲染指令无法执行。

为了解决这个问题,可以使用Canvas.drawLine()来画border的一条边,CPU将负责这个draw操作,不存在OpenGL ES渲染操作,也就不会出现上述异常。

在使用Path描述border的一条边的时候,可以同时使用PathEffect来描述border-style。如果使用了Canvas.drawLine()替代Canvas.drawPath(),将无法使用PathEffect。为此,可以使用LinearGradient来实现border-style

LinearGradient是一种Shader,一般用来实现颜色渐变。为了实现border-x-style:dashedborder-x-style:dotted效果,可以通过将渐变区域的长度设置为0,渐变颜色分别设置为border-x-colortransparent来解决此问题。

对比

React Native Android和Weex Android在解决border问题上都采取了类似的方案,即自定义Drawable对象,在Canvas上把border画出来。然而在如何绘制border这个方面,React Native Android和Weex Android存在着明显的区别,对border-x-width, border-x-color, border-x-style, border-x-y-radius的支持能力也有明显差异,在细节的处理上也有不同之处。

Pros

Weex Android对border支持能力比React Native Android强,对一些corner case也有一定程度的支持。具体列举如下

  • 全面支持border-x-width, border-x-color, border-x-style, border-x-y-radius四个属性。

    • React Native Android不支持border-x-style,只支持border-style
    • 如果存在border-x-y-radius,则border-x-color,border-y-color失效,退化成border-colorborder-x-width, border-y-width也会失效,退化成border-width
  • Weex Android支持某条border超过6000像素,而React Native Android会拒绝渲染这条边。
  • Weex Android对图片的border-radius支持不依赖于图片库,开发者可以自行更换图片库而不影响border,而React Native Android的border-radius依赖于fresco图片库。

Cons

Weex Android对border邻接两边的过渡区域处理不够圆滑,当border同时满足下面两个条件的时候

  • border-x-y-radius大于0
  • border-x-width != border-y-width

此时过渡区域无法满足CSS Recommendation中对过渡区域的定义

the center of color and style transitions between adjoining borders is a point along the curve that is a continuous monotonic function of the ratio of the border widths.

而React Native Android由于在border-x-y-radius存在时,border-x-widthborder-y-width会退化成border-width,不按照前端指定的CSS属性渲染,因此不满足上述前置条件,从而使得此问题不会发生。

例如对于这段weex代码片段


<img src="http://www.fresher.ru/manager_content/images2/kadry-veka/big/2-1.jpg" 
          style="width:200px; height:300px; background-color:#0000ff;margin:50px;
          box-sizing:border-box;
          padding: 20px;
          border-color:#000000;
          border-style:solid;
          border-top-width: 30px;
          border-left-width: 50px;
          border-right-width: 30px;
          border-top-left-radius: 180px;
          border-top-right-radius: 220px"/>

获得了如下的渲染效果,可以看到下图中left-edge和top-edge的过渡区域不够圆滑。

Weex Android Border绘制

总结

Weex Android和React Native Android对border的整体解决方案类似。在实现细节上,

  • React Native Android选择支持更少的corner case,在一些情况下,可能会不依照前端指定的CSS属性渲染,从而使得渲染效果符合CSS Recommendation。
  • Weex Android选择支持尽可能多的corner case,兼容前端的各种border写法,但可能会牺牲border邻接两边的过渡区域的渲染效果。

参考文章