如前所述,部件是android.view.View的常用简称,它通常是视图树的叶子节点。视图树的内部节点,虽然可能包含复杂的代码,但通常这些节点在用户交互方面更简单。部件这个术语虽然不正式,但是对于讨论包含用户关心的信息和行为的用户界面的工作部件是有用的。
你无需创建一个新的部件就可以实现很多功能。在本书中,我们已经构建的应用,使用的部件都是已有的,或者是已有部件的简单子类。这些应用只是构建视图树,通过代码或XML文件的布局资源来展开。
第9章将探讨MicroJobs应用,它包含一个视图,该视图的内容是在地图上标出一个名称列表。当在地图中添加其他位置时,新的显示名称的部件会被动态添加到列表中。这种动态变化的布局使用的也是已存在的部件,没有创建新的部件。从图形上看,MicroJobs应用的功能是向树结构中添加盒子,或从树结构中删除盒子,如第6章的图6-3所示。
本章将介绍如何自己动手创建部件,这需要探究视图(View)的结构。TextView、Button和DatePicker都是Android UI工具箱提供的部件。可以把自己的部件实现成这些部件的子类,或者直接创建一个View的子类。
更复杂的部件,如可以嵌套其他部件,其本身需要继承View。一个非常复杂的部件,可能会作为接口工具在多处实现(甚至在多个应用中使用),可能是整个类包,但只有一个类是View类的子类。
本章要介绍的是图形,因此内容是关于模型-视图-控制器(MVC)模式中视图这一部分的。部件也包含控制器代码,这也是一种良好的设计模式,因为它把行为及在屏幕上和展现相关的所有代码集中起来了。本章只介绍View的实现。控制器的实现在第6章已经探讨过了。
关于图形方面,可以分成两个基础部分:在屏幕上寻找空间,以及在该空间上绘图。第一个任务是布局(layout)。叶子部件使用onMeasure方法声明其空间需求,Android UI框架会在正确的时间调用该方法。第二个任务是真正渲染部件,通过部件的onDraw方法实现。
布局
Android框架的布局(layout)机制中的大多数繁重的任务是通过容器视图(container view)实现的。容器视图也是一个视图,其特别之处在于它包含其他视图,它在视图树中是内部节点,属于ViewGroup的子类。Android框架工具箱提供了各种各样的复杂容器视图,为屏幕布局提供了强大的自适应策略。简单举几个例子,如LinearLayout和RelativeLayout,都是相对易于使用并且很难重新正确实现的容器视图。既然这些便捷、强大的容器视图已经存在了,你可能不需要实现在这里所探讨的容器视图或布局算法。然而,了解它们是如何工作的(即Android UI框架管理布局的过程)有助于构建正确的、健壮的部件。
例8-1显示了一个非常简单的部件。如果把该部件添加到一些Activity的视图树中,则该部件会用青色填充分配给它的空间。在我们探讨创建更复杂的部件之前,先来仔细看看该实例如何完成绘图中的两个基本任务:布局和描绘。先来分析布局过程。在P219“Canvas绘画”一节中将描述绘图过程。
例8-1:一个简单的部件
public class TrivialWidget extends View { public TrivialWidget(Context context) { super(context); setMinimumWidth(100); setMinimumHeight(20); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { setMeasuredDimension( getSuggestedMinimumWidth, getSuggestedMinimumHeight); } @Override protected void onDraw(Canvas canvas) { canvas.drawColor(Color.CYAN); }}
因为部件的空间需求会动态变化,所以动态布局是必要的。举个例子,在支持GPS的应用中,有个部件的功能是显示你所在城市的名称。当你从Ely到Post Mills时,部件会接收到位置变化的通知。但是,当它准备重新描绘城市名称时,它可能注意到没有足够的空间来显示新城镇的全名。它会请求获得更多的空间对屏幕显示进行重新描绘,如果屏幕上有足够空间的话。
布局事实上是非常复杂的,要做到正确很困难。使某个叶子部件在某台设备上工作正常可能不是很困难。但是,要使部件做到使其子节点在不同的设备上的显示都正常,甚至能够自适应屏幕尺寸的变化,那是非常困难的。
当在视图树的某些视图上调用requestLayout方法时,会对布局过程执行初始化。通常情况下,当部件需要更多的空间时,它本身会调用requestLayout。然而,在应用的任何地方都可以调用该方法,表示当前屏幕的某些视图不再有足够的空间。
requestLayout方法引起Android UI框架向UI事件队列中插入一个事件。当执行到该事件时,框架允许每个容器视图询问其孩子部件需要多少空间。这个过程可以分解成两个阶段:测量孩子视图需要的空间;把孩子视图调整到新的空间。所有视图都必须实现第一个阶段,但是第二个阶段只有那些需要管理孩子视图布局的容器视图才需要实现。
测量
测量阶段的目标是使每个视图能够动态请求理想情况下描绘所需要的空间。UI框架从调用视图树的根节点视图的measure方法开始执行这个过程。每个容器视图询问其孩子视图需要多少空间。该调用会以深度优先的方式递归到所有的后代,因此每个孩子视图在父亲节点之前先计算其需要的空间。父亲节点的大小基于孩子节点的大小来计算,然后向上汇报给其父亲节点,直到树的根节点。
例如,在P167“组装图形界面”一节中,最上方的LinearLayout询问每个嵌套的LinearLayout部件需要多少空间。这些部件又询问嵌套在它内部的Button或EditText需要多少空间。每个孩子节点告诉其父亲节点它们所需的空间大小。父亲节点把孩子节点需要的空间加起来,再加上它们自己填充的大小,然后把总和报告给最上层的LinearLayout。
因为框架必须保证这个过程中所有视图的某些行为,所以measure方法是final类型,不能够被覆盖,而measure方法会调用onMeasure方法,部件可以通过覆盖onMeasure方法来声明其所需要的空间。
onMeasure方法的参数是父亲节点能够提供的空间大小:宽度和高度,以像素为单位。Android框架假定所有的视图大小都在[0,2 30]像素区间内,因此,整型参数的高字节位被用来存储传递测量规格模式(measurement specification mode)。onMeasure方法虽然只有2个参数,但从逻辑上看实际上是4个参数:宽度规格模式、宽度、高度规格模式和高度。不要尝试自己通过移位操作来获取参数!相反,应该使用静态方法MeasureSpec.getMode和MeasureSpec.getSize来获取。
规格模式描述了容器视图希望孩子节点如何解释其关联的大小,它包含3个不同的值:
MeasureSpec.EXACTLY
容器视图调用方已经指定了孩子视图的精确大小。
MeasureSpec.AT_MOST
容器视图调用方设置了最大值,孩子视图可以请求更少的空间。
MeasureSpec.UNSPECIFIED
容器视图对孩子视图没有限制,孩子视图可以随意请求大小。
部件需要将它所需要的空间告诉其视图树中的父亲节点。首先,部件调用setMeasuredDimensions设置其高度和宽度属性。然后,其父亲节点可以调用方法getMeasuredHeight和getMeasuredWidth来获取这些属性。如果你的实现覆盖了onMeasure方法,但是没有调用setMeasuredDimensions方法,则measure方法会抛出IllegalStateException异常,而不会正常执行。
onMeasure方法继承自View,其实现中必须调用setMeasuredDimensions,在每个方向设置一个值。如果父亲视图指定模式为MeasureSpec.UNSPECIFIED,则孩子视图的setMeasuredDimensions方法使用视图的默认大小,其值由getSuggestedMinimumWidth或getSuggestedMinimumHeight提供。如果父亲视图指定的是MeasureSpec.EXACTLY或MeasureSpec.AT_MOST,则孩子视图的默认大小使用父亲视图给出的大小。这种策略非常合理,它使得部件在测量阶段的处理只需要简单地设置成getSuggestedMinimumWidth和getSuggestedMinimumHeight所返回的值。
你要实现的部件可能无法获取其请求大小的空间。假设有个宽度为100像素的视图,它包含3个孩子视图。如果其孩子视图请求的宽度总和小于等于100像素,那可能很容易调整;但是,如果每个孩子视图请求50像素,那么父亲视图就无法满足全部需求。
容器视图可以完全控制如何给孩子视图分配大小。在前面给出的例子中,它可以采取“平均”的方式,给每个孩子分配33像素;也可以给最左边的孩子视图分配50像素,剩下两个视图都分配25像素。实际上,它还可以把100像素全部给某个孩子视图,其他两个视图一个像素都没有。无论是哪种方式,父亲视图最终都需要确定每个孩子的边界矩形框的大小和位置。
容器视图控制分配给部件的空间大小的另一个例子是如例8-1所示的部件示例。该部件总是请求其想要的空间大小,不管分配给它的空间是多少(和默认实现不同)。该策略对于记住要加到工具箱容器的部件很方便,特别是实现了gravity的LinearLayout。gravity是一些视图用来指定其子元素的排列方式的一个属性。当你第一次使用其中某个容器时,当发现默认情况下定制的部件中只描绘了第一个部件时,你可能会感到很惊讶。可以使用setGravity方法把属性修改成Gravity.FILL,或让你的部件指定请求的空间量来解决这个问题。
还应该注意的是,容器视图在单个测量阶段,可能会多次调用孩子视图的measure方法。作为onMeasure方法实现的一部分,一个巧妙的容器视图,想要把部件水平排列,可能会调用每个孩子部件的measure方法,其模式为MEASURE_SPEC.UNSPECIFIED,宽度为0,从而找到该部件想要的大小。一旦它收集到每个孩子视图期望的宽度,它会对这些宽度求和,比较求和值和实际可用的宽度(在父亲视图调用其measure方法时会指定)。现在,它可能调用每个孩子部件的measure方法,把模式设置成MeasureSpec.AT_MOST,宽度设置成实际可用的空间。因为可以多次调用measure方法,所以onMeasure方法的实现必须是幂等(idempotent)性的,而且不能够改变应用的状态。
注意:如果多次执行某个动作的效果和一次执行的效果相同,我们就说这个动作是“幂等性”的。例如,x=3这个语句就是幂等性的,因为不管你执行多少次,x的结果都是3。但是,x=x+1不是幂等性的,因为x的值取决于该语句所执行的次数。
容器视图的onMeasure方法的实现很可能是相当复杂的。所有容器视图的超类ViewGroup,并没有提供默认的实现。每个Android UI框架容器视图包含自己的实现。如果你考虑实现一个容器视图,那么可能会考虑继承某个框架容器视图。相反,如果你从头开始实现,则可能还是需要为每个孩子视图调用measure方法,并考虑使用ViewGroup提供的辅助方法:measureChild、measureChildren和measureChildWithMargins。
在测量阶段的最后,容器视图与其他部件一样,必须调用setMeasuredDimensions方法报告其需要的空间大小。
布置
一旦视图树中的所有容器视图有机会声明其每个孩子视图的大小,框架就启动第二次布局,即布置调整其孩子的空间。同样,除非你自己实现了容器视图,否则你很可能永远都没有机会实现自己的调整代码。这一节介绍的是其底层过程,了解其内部机制有助于更好地了解它会如何影响你的部件。View中实现的默认方法适用于传统的叶子部件,如例9-1所示。
因为视图的onMeasure方法可能会被调用多次,所以框架必须使用另一种方法来指明测量阶段是否结束,容器视图必须确定其孩子视图的最终位置。和测量阶段类似,布置阶段是通过两种方法实现的。框架调用视图树根节点的final方法layout。layout方法执行所有视图都有的处理,然后调用onLayout,它定制部件覆盖实现自己的行为。定制的onLayout的实现至少必须计算它在描绘时会提供给每个孩子节点的边界矩形框,并顺序调用每个孩子视图的layout方法(因为孩子视图可能是其他部件的父亲视图)。这个过程可能是非常复杂的。如果你的部件需要调整孩子视图,你可能会考虑让它基于已有的容器,比如LinearLayout或RelativeLayout。
值得再次强调的是,部件请求的空间大小是不能确保的。它必须准备好以实际分配给它的空间大小进行描绘。如果它尝试在父亲节点所分配的空间之外描绘,则这些超出部分会被剪辑矩形框裁掉(在本章后面会探讨它)。要很好地控制描绘空间,比如精确填充分配给它的空间,部件必须实现onLayout并记录分配的空间的维度,或者查看Canvas的剪辑矩形框,Canvas是onDraw方法的参数。
Canvas绘画
我们已经探讨了部件是如何在屏幕上分配空间并绘制的。下面一起来实现几个部件,看它们是如何工作的。
既然你已经了解了视图的测量和布置(arrangement),Android UI框架处理绘制的方式应该很熟悉了。当应用的某些部分认为当前的屏幕绘制由于状态变化过时了,那么它就会调用View的invalidate方法。该调用会在事件队列中插入一个重新绘制事件。
当重新绘制事件被处理时,框架会调用视图树根节点的draw方法。这个调用会按照前序遍历的方式迭代执行,每个视图先绘制自己,然后再调用孩子视图的draw方法。这意味着叶子视图是在其父亲视图之后绘制的,依此类推。在树的较下方的View是在那些靠近树的根节点的视图上绘制的。
View.draw方法调用onDraw,每个子类可以通过覆盖onDraw方法来实现自己定制的渲染。当调用你的widget的onDraw方法时,它必须根据应用的当前状态进行渲染并返回。虽然View.draw和ViewGroup.dispatchDraw(负责视图树的遍历)都不是final类型,但是如果覆盖它们,将会给自己带来麻烦!
为了防止绘制超出范围,Android UI框架维护了视图的一些状态信息,称为剪辑矩形框(clip rectangle)。剪辑矩形框是Android UI框架的一个关键概念,它是调用组件的图形渲染方法所传递的状态参数的一部分。它包含位置和大小,可以通过画布方法获取并调整。它就像一个模具,组件通过它执行所有的绘制:组件只能在剪辑矩形框可见的画布部分进行绘制。只要正确地设置了剪辑矩形框的大小、形状和位置,Android UI框架就可以防止组件的绘制超出其边界,也可以防止组件对已经正确绘制的区域进行重新绘制。
在Android API 7中提供了另一个优化工具:Eclair。如果一个视图是非透明的(其矩形框填充都是非透明的对象)应该重载视图方法isOpaque,返回布尔值true。这样,widget就会告诉绘制算法不需要对其他视图进行渲染。即使只是在一个不是很复杂的视图树中,这也会减少一个像素被绘制次数的80%或75%(即原来需要绘制四次或五次,现在只需要绘制一次)。这个优化带来的明显的效果是滚动条拖曳变得很平滑,不再迟钝了!
在探讨绘制的细节之前,我们再次说明一下Android的单线程MVC设计模式。有两个基本规则:
·绘制代码应该在onDraw方法之内。当调用onDraw方法时,部件应该完全绘制自己,显示程序状态。
·当onDraw方法被调用时,部件应该尽快绘制。onDraw调用中间不适合运行复杂的数据库查询或与某些远程网络服务交互。需要绘制的所有状态都应该缓存,以便绘制。长时间运行的任务应该在不同的线程中执行,以及使用一种在P186“高级连接:聚集和线程化”一节中所描述的机制。视图中缓存的模型状态信息有时称为视图模型(view model)。
Android UI框架在绘图时使用4个主要的类。如果要实现定制的部件,并执行自己的绘制,需要对这4个类非常熟悉:
Canvas(类android.graphics.Canvas的子类)
Canvas在现实生活中没有明确的类比。可以把它想成一个复杂的画架,可以旋转方向、弯曲甚至可以通过有趣的方式弄皱你绘制的图纸。它包含绘制需要的模具——剪辑矩形框。它还可以对绘制的图形进行扩展,类似相片放大器。它还可以执行其他的转换操作,这些操作更难通过类比来说明:对颜色进行映射及沿路径绘制文本。
Paint(类android.graphics.Paint的子类)
在绘制时需要Paint这个工具。它控制颜色、透明度和画笔的大小。它还可以控制绘制的文本的字体、大小和格式。
Bitmap(类android.graphics.Bitmap的子类)
在Bitmap上进行绘制。它显示绘制时的实际像素。
Drawable(可能是android.graphics.drawable.Drawable的子类)
Drawable是要绘制的事物:矩形框或图像。虽然你所绘制的不全是Drawable(比如文本不是),但是很多属于Drawable,尤其是那些复杂的图形。
例8-1是只使用Canvas作为onDraw方法的参数所绘制的绘制。为了绘制一些更有意义的图形,我们至少需要Paint。Paint提供了对所绘制图形的颜色和透明度(alpha)的控制。它还控制绘制这些图形所使用的画笔的大小。当和文本绘制方法一起使用时,Paint控制文本的字体、大小和格式。Paint还包含很多其他功能,有些将在P235“Bling”一节中给出。尽管如此,例8-2还是可以作为了解Paint的入门示例。该例子设置了Paint控制的两个参数(颜色和画笔宽度),先描绘粗的垂直线,然后是一系列水平线。每条绿线的alpha值(和RGB web颜色的第4个值功能相同)逐渐减少,使得看起来更透明。要了解更多有用的属性,请参看Paint类文档。
例8-2:使用Paint
@Overrideprotected void onDraw(Canvas canvas) { canvas.drawColor(Color.WHITE); Paint paint = new Paint; canvas.drawLine(33, 0, 33, 100, paint); paint.setColor(Color.RED); paint.setStrokeWidth(10); canvas.drawLine(56, 0, 56, 100, paint); paint.setColor(Color.GREEN); paint.setStrokeWidth(5); for (int y = 30, alpha = 255; alpha > 2; alpha >>= 1, y += 10) { paint.setAlpha(alpha); canvas.drawLine(0, y, 100, y, paint); }}
图8-1显示了示例代码所创建的图形。
图8-1:Paint输出
除了Paint,绘制出一个实用的部件,还有另外几个必要的工具。例如,例8-3中的代码绘制出的就是例6-7中的部件。虽然不是很复杂,但是它包含了功能完备的部件所有要素。它处理布局,使用高亮(不管视图是否包含用户焦点),显示其关联的模型的状态。部件描绘了一系列的点,其信息保存在一个私有数组中。每个点指定其本身的x和y坐标,以及直径和颜色。OnDraw函数重新设置Paint的颜色,使用其他参数指定由画布的drawCircle方法绘制的圆。
例8-3:点部件
package com.oreilly.android.intro.view;import android.content.Context;import android.graphics.Canvas;import android.graphics.Color;import android.graphics.Paint;import android.graphics.Paint.Style;import android.view.View;import com.oreilly.android.intro.model.Dot;import com.oreilly.android.intro.model.Dots;public class DotView extends View { private final Dots dots; /** * @param context the rest of the application * @param dots the dots we draw */ public DotView(Context context, Dots dots) { super(context); this.dots = dots; setMinimumWidth(180); setMinimumHeight(200); setFocusable(true); } /** @see android.view.View#onMeasure(int, int) */ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { setMeasuredDimension( getSuggestedMinimumWidth, getSuggestedMinimumHeight); } /** @see android.view.View#onDraw(android.graphics.Canvas) */ @Override protected void onDraw(Canvas canvas) { canvas.drawColor(Color.WHITE); Paint paint = new Paint; paint.setStyle(Style.STROKE); paint.setColor(hasFocus ? Color.BLUE : Color.GRAY); canvas.drawRect(0, 0, getWidth - 1, getHeight - 1, paint); paint.setStyle(Style.FILL); for (Dot dot : dots.getDots) { paint.setColor(dot.getColor); canvas.drawCircle( dot.getX, dot.getY, dot.getDiameter, paint); } }}
通过Paint,我们有足够的空间来探索Canvas方法。但是,有两组功能需要特别注意。
绘制文本
在Canvas方法中,最重要的当属那些绘制文本的方法。Canvas的有些功能在其他类中也有,但文本渲染功能是它特有的。要在部件中放置文本,必须使用Canvas类(或者继承了该类的其他部件)。
Canvas提供了一些渲染文本的功能,借助这些功能可以很方便地对文本中每个字符的位置进行布置。其方法是成对出现的:一个以String为参数,另一个以char数组为参数。在某些情况下,还有几种其他方式。例如,绘制文本的最简单的方式是传递文本开始的x坐标和y坐标,以及指定其字体、颜色和其他属性的Paint类(见图8-4)。
例8-4:一组文本绘制方法
public void drawText(String text, float x, float y, Paint paint)public void drawText(char text, int index, int count, float x, float y, Paint paint)
第一种方法只需要一个String参数来传递文本,第二种方法使用了3个参数:char数组、表示要绘制的数组的第一个字符的偏移及要绘制的文本的字符数。
如果你想要一些比简单的水平文本更丰富的功能,可以沿着几何线绘制它甚至把字符放到想要的任何位置。例8-5包含onDraw方法,它说明了3个文本渲染方法的使用。其输出如图8-2所示。
例8-5:3种绘制文本的方式
@Overrideprotected void onDraw(Canvas canvas) { canvas.drawColor(Color.WHITE); Paint paint = new Paint; paint.setColor(Color.RED); canvas.drawText("Android", 25, 30, paint); Path path = new Path; path.addArc(new RectF(10, 50, 90, 200), 240, 90); paint.setColor(Color.CYAN); canvas.drawTextOnPath("Android", path, 0, 0, paint); float pos = new float { 20, 80, 29, 83, 36, 80, 46, 83, 52, 80, 62, 83, 68, 80 }; paint.setColor(Color.GREEN); canvas.drawPosText("Android", pos, paint);}
图8-2:绘制文本的3种输出
你可能已经注意到了,最基础的功能drawText只是在传递的坐标处开始绘制文本。但是,DrawTextOnPath方法可以沿着任何路径绘制文本。示例路径只是一个弧形。它也可以绘制直线或贝赛尔曲线。
对于DrawTextOnPath还无法满足的功能,Canvas提供DrawPosText方法,它支持指定文本中每个字符的位置。注意,字符位置是通过数组元素指定的:x1,y1,x2,y2...
矩阵转换
第二组有趣的Canvas方法是矩阵转换及相关的方法:rotate、scale和skew。这些方法对所绘制的图形使用那些其他环境下众所周知的一些三维图形转换方式来处理它。该方法使得单一图形的绘制方式看起来像是在和绘制的图形一起运动。
例8-6中的小应用演示说明了Canvas的坐标转换功能。
例8-6:在画布中使用转换功能
import android.app.Activity;import android.content.Context;import android.graphics.Canvas;import android.graphics.Color;import android.graphics.Paint;import android.graphics.Rect;import android.os.Bundle;import android.view.View;import android.widget.LinearLayout;public class TranformationalActivity extends Activity { private interface Transformation { void transform(Canvas canvas); String describe; } private static class TransformedViewWidget extends View {① private final Transformation transformation; public TransformedViewWidget(Context context, Transformation xform) { super(context); transformation = xform;② setMinimumWidth(160); setMinimumHeight(105); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { setMeasuredDimension( getSuggestedMinimumWidth, getSuggestedMinimumHeight); } @Override protected void onDraw(Canvas canvas) {③ canvas.drawColor(Color.WHITE); Paint paint = new Paint; canvas.save;④ transformation.transform(canvas);⑤ paint.setTextSize(12); paint.setColor(Color.GREEN); canvas.drawText("Hello", 40, 55, paint); paint.setTextSize(16); paint.setColor(Color.RED); canvas.drawText("Android", 35, 65, paint); canvas.restore;⑥ paint.setColor(Color.BLACK); paint.setStyle(Paint.Style.STROKE); Rect r = canvas.getClipBounds; canvas.drawRect(r, paint); paint.setTextSize(10); paint.setColor(Color.BLUE); canvas.drawText(transformation.describe, 5, 100, paint); }} @Override public void onCreate(Bundle savedInstanceState) {⑦ super.onCreate(savedInstanceState); setContentView(R.layout.transformed); LinearLayout v1 = (LinearLayout) findViewById(R.id.v_left);⑧ v1.addView(new TransformedViewWidget(⑨ this, new Transformation {⑩ @Override public String describe { return "identity"; } @Override public void transform(Canvas canvas) { } } )); v1.addView(new TransformedViewWidget(⑨ this, new Transformation {⑩ @Override public String describe { return "rotate(-30)"; } @Override public void transform(Canvas canvas) { canvas.rotate(-30.0F); } })); v1.addView(new TransformedViewWidget(⑨ this, new Transformation {⑩ @Override public String describe { return "scale(.5,.8)"; } @Override public void transform(Canvas canvas) { canvas.scale(0.5F, .8F); } })); v1.addView(new TransformedViewWidget(⑨ this, new Transformation {⑩ @Override public String describe { return "skew(.1,.3)"; } @Override public void transform(Canvas canvas) { canvas.skew(0.1F, 0.3F); } })); LinearLayout v2 = (LinearLayout) findViewById(R.id.v_right);⑪ v2.addView(new TransformedViewWidget(⑫ this, new Transformation {⑩ @Override public String describe { return "translate(30,10)"; } @Override public void transform(Canvas canvas) { canvas.translate(30.0F, 10.0F); } })); v2.addView(new TransformedViewWidget(⑫ this, new Transformation ⑩ @Override public String describe { return "translate(110,-20),rotate(85)"; } @Override public void transform(Canvas canvas) { canvas.translate(110.0F, -20.0F); canvas.rotate(85.0F); } })); v2.addView(new TransformedViewWidget(⑫ this, new Transformation {⑩ @Override public String describe { return "translate(-50,-20),scale(2,1.2)"; } @Override public void transform(Canvas canvas) { canvas.translate(-50.0F, -20.0F); canvas.scale(2F, 1.2F); } })); v2.addView(new TransformedViewWidget(⑫ this, new Transformation {⑩ @Override public String describe { return "complex"; } @Override public void transform(Canvas canvas) { canvas.translate(-100.0F, -100.0F); canvas.scale(2.5F, 2F); canvas.skew(0.1F, 0.3F); } })); }}
上面的示例代码的运行结果如图8-3所示。
以下是代码的关键点解释:
① 定义新的widget的TransformedViewWidget。
② 根据构造函数的第二个参数执行实际转换。
③ TransformedViewWidget的onDraw方法。
④ 在执行任何转换之前,都应先用save函数把当前的绘制状态保存到栈中。
⑤ 执行构造函数第二个参数中指定的转换操作。
⑥ 恢复在第4项中保存的先前状态,准备绘制矩形框和标签。
⑦ Activity的onCreate方法。
⑧ 为左侧的部件创建容器视图。
⑨ 对TransformedViewWidget实例化,并将其添加到左侧的列中。
⑩ 创建转换,作为TransformedViewWidget构造函数的参数。
为右侧的部件创建容器视图。
对TransformedViewWidget实例化,添加到右侧列中。
图8-3:转换后的视图
这个小的应用程序引入了一些新的思想。对于视图和部件,应用定义了TransformedViewWidget,并创建了8个实例。对于布局,应用创建了两个视图,名为v1和v2,从数据源获取参数。然后给每个LinearLayout视图添加4个实例。该例子说明了应用如何把基于源的视图和动态视图结合起来。注意,布局视图的创建和新widget的构造函数都是在Activity的onCreate方法中完成的。
该应用在部件和父亲视图之间做到了良好的分离,使得这个部件非常灵活。一些简单的对象是直接在TransformedViewWidget的onDraw方法中定义的范围内绘制的:
·白色背景
·单词hello,字体大小为12号,绿色
·单词Android,字体大小为16号,红色
·黑色框
·蓝色标签
在这块代码的中间部分,onDraw方法执行了调用者所指定的转换。应用定义其自己的接口,名称为Transformation;以及以Transformation为参数的TransformedViewWidget构造函数。下面将很快说明调用者是如何真正执行转换的。
首先,应该查看onDraw在转换过程中是如何保存它自己的文本的。在这个例子中,需要最后绘制框架和标签,这样它们就在其他部件所绘制的图形的上方绘制了,即使这些绘制可能存在重叠。我们不希望转换影响到框架或标签。
幸运的是,Canvas维护了一个内部栈,可以通过该栈保存或恢复转换矩阵、剪辑矩形框及Canvas中所有其他可变的状态元素。通过栈,onDraw方法调用Canvas.save方法保存其转换之前的状态,调用Canvas.restore方法恢复之前所保存的状态。
应用的剩余部分控制了应用于TransformedViewWidget的每个实例。部件的每个新实例都是通过自己的Transformation匿名实例创建的。处于标签为identity的图形区域中的对象不做任何转换。其他7个区域打上转换标签。
Canvas转换的基础方法是setMatrix和concatMatrix。通过这两个方法可以创建各种转换。使用getMatrix方法,可以创建动态构建的矩阵用于后期使用。这个例子中所给出的方法——translate、rotate、scale和skew,是在当前Canvas状态中添加个性化的、带限定条件的矩阵时的方便方法。
虽然开始阶段可能不是很明显,但是这些转换功能可能是非常有用的。它们可以让你的应用根据三维对象转变其可视化的点。例如,很显然,查看标签为scale(.5,.8)的方框和标签为identity的方框的方法相同,但是其视觉差别很大。不难想象,在标签为skew(.1,.3)的框内的图形可能是没有经过转换的,但是它是从上方稍侧边上查看的。对任何对象缩放或转换会使用户感觉该对象动了。倾斜和旋转会让人感觉该对象被打开了。
当你考虑把这些转换功能应用到画布上的所有对象上时(线条、文字甚至图形),其在应用中的重要性变得更加明显。缩略图的实现可以简单地把所有事物的显示缩放到原尺寸大小的10%,虽然这种方式可能不是最优的。显示你在开车时所看到的左侧事物的应用可以通过对几个图形进行缩放和倾斜来实现。
Drawable
Drawable是个可以在画布上渲染自己的对象。因为Drawable在渲染时可以完全控制,即使是非常复杂的渲染过程也可以执行封装,因此非常易于使用。
例8-7和例8-8显示了使用Drawable实现图8-3所示的例子所需要的变换。绘制红色和绿色文本的代码已经重构到HelloAndroidTextDrawable类中,通过widget的onDraw方法渲染。
例8-7:使用TextDrawable
private static class HelloAndroidTextDrawable extends Drawable { private ColorFilter filter; private int opacity; public HelloAndroidTextDrawable {} @Override public void draw(Canvas canvas) { Paint paint = new Paint; paint.setColorFilter(filter); paint.setAlpha(opacity); paint.setTextSize(12); paint.setColor(Color.GREEN); canvas.drawText("Hello", 40, 55, paint); paint.setTextSize(16); paint.setColor(Color.RED); canvas.drawText("Android", 35, 65, paint);} @Override public int getOpacity { return PixelFormat.TRANSLUCENT; } @Override public void setAlpha(int alpha) { } @Override public void setColorFilter(ColorFilter cf) { }}
使用新的Drawable实现只需要对例子中的onDraw方法做很少的变换。
例8-8:使用Drawable widget
package com.oreilly.android.intro.widget;import android.content.Context;import android.graphics.Canvas;import android.graphics.Color;import android.graphics.Paint;import android.graphics.Rect;import android.graphics.drawable.Drawable;import android.view.View;/**A widget that renders a drawable with a transformation */public class TransformedViewWidget extends View { /** A transformation */ public interface Transformation { /** @param canvas */ void transform(Canvas canvas); /** @return text description of the transform. */ String describe; } private final Transformation transformation; private final Drawable drawable; /** * Render the passed drawable, transformed. * * @param context app context * @param draw the object to be drawn, in transform * @param xform the transformation */ public TransformedViewWidget( Context context, Drawable draw, Transformation xform) { super(context); drawable = draw; transformation = xform; setMinimumWidth(160); setMinimumHeight(135); } /** @see android.view.View#onMeasure(int, int) */ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { setMeasuredDimension( getSuggestedMinimumWidth, getSuggestedMinimumHeight); } /** @see android.view.View#onDraw(android.graphics.Canvas) */ @Override protected void onDraw(Canvas canvas) { canvas.drawColor(Color.WHITE); canvas.save; transformation.transform(canvas); drawable.draw(canvas); canvas.restore; Paint paint = new Paint; paint.setColor(Color.BLACK); paint.setStyle(Paint.Style.STROKE); Rect r = canvas.getClipBounds; canvas.drawRect(r, paint); paint.setTextSize(10); paint.setColor(Color.BLUE); canvas.drawText( transformation.describe, 5, getMeasuredHeight - 5, paint); }}
这段代码显示了Drawable的强大之处。TransformedViewWidget可以对任何Drawable对象进行转换,不管绘制的是什么。它不再和原始的硬编码文本关联。它可以用于对之前示例的文本及相片进行转换,如图8-4所示。它甚至还可以用于对Drawable动画进行转换。
图8-4:对包含相片的视图进行转换
Drawable使得复杂的图形技术(如动画)变得可追踪。此外,因为它们把渲染过程完全封装起来了,所以利用Drawable可以把复杂的渲染分解成小的可重用的组件。
思考一下,扩展一下前面的例子,使得每个图形在一分钟内褪成白色。当然,修改例9-8中的代码可以实现这个功能。还可以实现一个不同的且很有意思的新的Drawable。
我们将创建一个名为FaderDrawable的新Drawable,其构造函数的参数是Drawable的引用。此外,它必须包含一个时间概念,可以是一个整数,我们称之为t,它的值随着计时器变化。每当调用FaderDrawable的draw方法时,它首先调用其目标draw方法。但是,下一步它会在同一个区域内用白色绘制,根据t的取值来决定绘制的透明度(alpha值)(如例9-2所示)。随着时间推移,t值变大,白色变得更不透明,目标Drawable就会逐渐褪成白色。
这个FaderDrawable示例说明了Drawable的一些重要功能。首先,FaderDrawable可以重复使用。它可以淡出任何Drawable对象。还要注意的是,由于FaderDrawable扩展了Drawable,任何Drawable可用的地方都可以使用FaderDrawable。在渲染过程中使用Drawable的任何代码都可以使用FaderDrawable,不需要做任何修改。
当然,FaderDrawable本身也可以封装。实际上,只需要构建一系列的Drawable封装,就可以实现非常复杂的效果。Android工具箱提供了Drawable封装来支持这个策略,包括ClipDrawable、RotateDrawable和ScaleDrawable。
现在,你可能会在思考通过Drawable重新设计整个UI。虽然Drawable很强大,但它们也不是万能的。在考虑使用Drawable时,有几点需要记住。
你可能已经注意到,Drawable类和View类有很多功能相同:位置、维度和可见性等。什么时候View应该直接在Canvas上绘制,什么时候应该委托给子视图,以及什么时候应该委托给一个或多个Drawable对象,这些不太容易把握。有一个DrawableContainer类,它支持在一个父亲视图内把几个子Drawable结合起来。可以构建Drawable树来取代目前所构建的View树。在使用Android UI框架时,需要相信“条条道路通罗马”,同一个功能有很多实现方式。
View和Drawable之间的区别之一在于Drawable没有实现View的测量/布局协议,正如前文所述,该协议支持容器视图改变其组件的布局及对视图大小进行调整。当一个可渲染的对象需要添加、删除或布置内部组件的布局时,很显然,实现时应该采用View而不是Drawable。
要考虑的第二点是,由于Drawable对象把绘制过程完全封装起来了,它们不会像String或Rect那样绘制。比如,没有Canvas方法在渲染时可以把Drawable对象放置在特定坐标处。你可能还会考虑为了渲染某个图像两次,View.onDraw方法应该使用两个Drawable对象还是使用单个Drawable对象两次却只是重新设置其坐标。
但是,还有一个最重要的也可能是更常见的问题。Drawable可以工作的原因在于Drawable接口不包含Drawable内部实现的任何细节。当你的代码传递一个Drawable对象时,无法知道它是要渲染某个图形还是一系列复杂的效果:旋转、闪烁、跳动等。当然,这是Drawable的一个很大的优点,但它也可能会带来问题。
绘制过程大部分是有状态的。设置Paint,然后绘制它;设置Canvas剪辑区和转换,然后绘制它。当是Drawable链时,那么必须慎重,因为要确保状态变化之间不会有冲突。问题在于当构建Drawable链时,从对象类型的定义上(都是Drawable对象),无法预见冲突。一个看似微小的变化可能带来预期之外的效果,而且很难调试。
为了说明这一点,我们假设有两个Drawable封装类,一个是要缩小显示其内容,另一个是要旋转90°。如果两个类都是通过把转换矩阵设置成某个具体值来实现,两者结合之后的效果可能不理想。更糟的是,如果A封装B可能工作良好,而如果B封装A就遭了!有必要仔细查看Drawable文档,了解它是如何实现的。
位图
位图(Bitmap)是绘制的4个基础项的最后一项:要绘制的目标(String、Rect等)、工具Paint、画布Canvas及存储绘制结果的位图。大多数情况下,不需要直接和Bitmap打交道,因为Canvas提供给onDraw方法的参数中已经隐含了一个Bitmap。但是,有些时候可能要直接使用Bitmap。
Bitmap的常见用途是缓存一个绘制很费时而且不会经常变换的绘图。比如,假设一个绘制程序支持用户绘制多个图层。图层在基本图像上透明叠加,用户可以随便关闭和打开该图层。如果每次调用onDraw方法都绘制每个图层代价可能很高。相反,如果基于第一次显示来渲染整个绘图,包含所有可见的图层,然后仅当用户做出某个图层变化时,再重新绘制对应的单个图层。
例8-9显示了一种绘图的实现。
例8-9:Bitmap缓存
private class CachingWidget extends View { private Bitmap cache; public CachingWidget(Context context) { super(context); setMinimumWidth(200); setMinimumHeight(200); } public void invalidateCache { cache = null; invalidate; } @Override protected void onDraw(Canvas canvas) { if (null == cache) { cache = Bitmap.createBitmap( getMeasuredWidth, getMeasuredHeight, Bitmap.Config.ARGB_8888); drawCachedBitmap(new Canvas(cache)); } canvas.drawBitmap(cache, 0, 0, new Paint); } // ... definition of drawCachedBitmap}
该部件通常只把缓存位图cache复制传递给onDraw方法的Canvas。只有当缓存标记为过期时,调用invalidateCache才会真正调用drawCachedBitmap来渲染这个widget。
位图的最常见的应用方式是作为图形资源的编程表示。当资源是一个图形时,Resources.getDrawable会返回BitmapDrawable。
把这两个思想结合起来,缓存一个图像并把它封装到Drawable,也会是非常有趣的。它意味着任何可以绘制的事物也可以延迟处理。一个运用了本章所给出的所有技术的应用都可以绘制出房间里的家具(创建一个位图),然后绕着它转圈(采用矩阵转换)。
注意:有了Honeycomb后,Android的渲染架构有了很大变化。这些变化充分利用了不断增强的GPU处理能力,创建了一组全新的规则,来优化UI图形绘制。使用新的图形绘制机制时,采用缓存位图的方式可能比按需绘制它们效率更低。因此,在使用位图缓存前,应该优先考虑使用View.setLayerType。