此文已由作者游葳授权网易云社区发布。
欢迎访问网易云社区,了解更多网易技术产品运营经验。
写在开头
随着应用开发的深入,视觉同学在完成了页面的基本设计后,再也按耐不住心中的寂寞,开始对各种细节不满意,于是乎就会提出各种视觉优化的方案。作为开发人员,啥也别说了,你懂的,有困难要上,没困难,制造困难也要上。既然是优化提升的方案,那很多时候只使用系统提供的各种控件,或者只是简单的用Paint去进行图形颜色的绘制,已经满足不了视觉同志的胃口了,这就要求我们必须掌握Paint的进阶技巧,比如本文介绍的图像混合技术 - PorterDuffXfermode。
PorterDuffXfermode 简介
相信很多android开发同学和我一样,第一次看到这个ProterDuff单词都会觉得奇怪,这是个啥子意思呢。作为一个猪场员工,我当然是立刻马上用有道词典翻译了一下,结果啥也没搜出来。后来上网查了才知道,ProterDuff是两个人名的组合: Tomas Proter和 Tom Duff. 这两个人在1984年一起写了一篇名为《Compositing Digital Images》的论文。我们知道,一个像素是由ARGB四个分量组成的,该论文就论述了如何实现不同数字图像的像素之间是如何进行混合的,并提出了多种像素混合的模式。PorterDuffXfermode支持以下十几种像素颜色的混合模式,分别为:
CLEAR 计算方式:[0, 0],效果:清除
SRC 计算方式:[Sa, Sc];效果:只绘制源图像
DST 计算方式:[Da, Dc];效果:只绘制目标图像
SRC_OVER 计算方式:[Sa + (1 - Sa)*Da, Rc = Sc + (1 - Sa)*Dc] 说明:在目标图像的上方绘制源图像
DST_OVER 计算方式:[Sa + (1 - Sa)*Da, Rc = Dc + (1 - Da)*Sc];说明:在源图像的上方绘制目标图像
SRC_IN 计算方式:[Sa * Da, Sc * Da];说明:只在源图像和目标图像相交的地方绘制目标图像
DST_IN 计算方式:[Sa * Da, Sa * Dc];说明:只在源图像和目标图像相交的地方绘制目标图像
SRC_OUT 计算方式:[Sa * (1 - Da), Sc * (1 - Da)];说明:只在目标图像和源图像不相交的地方绘制目标图像
DST_OUT 计算方式:[Da * (1 - Sa), Dc * (1 - Sa)];说明:只在源图像和目标图像不相交的地方绘制源图像
SRC_ATOP 计算方式:[Da, Sc * Da + (1 - Sa) * Dc];效果:在目标图像和源图像相交的地方绘制源图像而在不相交的地方绘制目标图像
DST_ATOP 计算方式:[Sa, Sa * Dc + Sc * (1 - Da)];效果:在源图像和目标图像相交的地方绘制目标图像而在不相交的地方绘制源图像
XOR 计算方式:[Sa + Da - 2 * Sa * Da, Sc * (1 - Da) + (1 - Sa) * Dc];说明:在源图像和目标图像不相交的地方各自绘制,在重叠的地方不绘制任何内容
DARKEN 计算方式:[Sa + Da - Sa*Da, Sc*(1 - Da) + Dc*(1 - Sa) + min(Sc, Dc)];说明:变暗
LIGHTEN 计算方式:[Sa + Da - Sa*Da, Sc*(1 - Da) + Dc*(1 - Sa) + max(Sc, Dc)];说明:变亮
MULTIPLY 计算方式:[Sa * Da, Sc * Dc];说明:混合
ADD 计算方式:Saturate(S + D);说明:饱和度相加
S代表源像素,源像素的颜色值表示为[Sa, Sc],Sa中的a是alpha的缩写,Sa表示源像素的Alpha值,Sc中的c是颜色color的缩写,Sc表示源像素的RGB。D代表目标像素,目标像素的颜色值表示为[Da, Dc],Da表示目标像素的Alpha值,Dc表示目标像素的RGB。
合成后[]逗号前面的这一部分的值代表计算后的Alpha通道,而逗号后的这一部分的值代表计算后的颜色值,图形混合后的图片依靠这个矢量来计算ARGB的值。
一张容易被误解的神图
相信很多人在用到PorterDuffXfermode的时候都有看过这张图吧,这张图是Android的sdk下自带的API的Demo示例。但是如果按照这张图的示例进行开发的话,有时可能会达不到预期效果。比如第一种的CLEAR效果,乍一看该图,CLEAR达到的效果应该是把dst和src的图片全部都清空了,但这个其实是不对的,因为PorterDuffXfermode 的机制就是src与dst进行各种混合变化,在超出src范围内的区域是不起作用的,所以CLEAR只是把src所包含部分清除了,但是在图上看了,却是整个图层上啥都没有了,这个又是为什么呢?
这个秘密就藏在Demo的源码中,打开位于/Users/netease/Library/Android/sdk/samples/android-19/legacy/ApiDemos/src/com/example/android/apis/graphics目录下的Xfermodes.java文件,示例中创建dst和src图片的源码如下:
// create a bitmap with a circle, used for the "dst" image static Bitmap makeDst(int w, int h) { Bitmap bm = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); Canvas c = new Canvas(bm); Paint p = new Paint(Paint.ANTI_ALIAS_FLAG); p.setColor(0xFFFFCC44); c.drawOval(new RectF(0, 0, w*3/4, h*3/4), p); return bm; }
// create a bitmap with a rect, used for the "src" image static Bitmap makeSrc(int w, int h) { Bitmap bm = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); Canvas c = new Canvas(bm); Paint p = new Paint(Paint.ANTI_ALIAS_FLAG); p.setColor(0xFF66AAFF); c.drawRect(w/3, h/3, w*19/20, h*19/20, p); return bm; }
发现了吗?原来在示例中创建的dst和src的大小,不只是我们从图中看到的只是那两个圆圈和方块而已,而是整个图的范围,黄色和蓝色的区域其实只是dst和src的一部分而已,只是其他部分是透明的,让人容易误以为dst和src就那么大而已。所以,当都是w * h范围的src和dst用CLEAR模式进行混合后,才会出现全部都没有的效果。
假如就以实际看到的两个区域作为dst和src的大小来进行演示,效果又会是怎么样的呢?修改后的混合效果如下( 为了方便观察比较,我将填满整个页面充当背景的View的颜色设置成白色,每一个显示效果的小View的背景色设置为绿色):
可以看到,当dst和src都只是圆圈和方块大小是,CLEAR模式下就仅仅只是把方块区域给清除了,这个才是CLEAR的真实效果。那为什么上下两张图的清除效果又会有所差异呢,一个是显示当前View的绿色背景,一个则直接显示了底层View的白色背景呢?
嘿嘿,原因就在于对canvas图层(layer)的使用。第一个图的流程是先绘制绿色背景,然后再调用saveLayer新生成一个图层进行dst和src的CLEAR操作,操作完后调用restore退出该图层,将该图层合成到原图层上。第二个图的流程是首先生成一个新图层,然后在图层上绘制绿色背景,进行dst和src的操作,这个时候由于绿色背景和dst,src是处于同一layer,因此CLEAR操作会将该layer上方块区域的RGB色值全部设置为0,再将该layer合成到原图层上后,就把底下的白色背景显示出来了(详细代码可见附件)。很多时候我们应该需要的是第一个图的效果,因此在操作的时候就要使用saveLayer、restoreToCount的方法把混合操作放在新图层上进行了。
一个食栗
有一天,做视觉的胖大叔(是的,负责给我们做视觉的是个大叔,原来的妹纸被拉去做官网视觉了。。。)跑过来给我说,诶,这个意见反馈发送图片的效果要改啊,要改的和微信的效果一样(微信聊天界面发送图片是什么效果,我觉得我就不用上图了吧)。额,是不是做聊天的都要向微信学(抄)啊,嘿嘿。好吧,原来那种简单的直接给ImageView添加一个背景的方式是用不了咯,想想怎么搞吧。
一开始的想法是利用剪裁的方式,把图片按照背景泡泡图片的尺寸进行裁剪,但是这个计算就比较累,而且那个尖角是什么鬼?这个要怎么计算。在纸上画了半天了,突然脑子里闪过“ PorterDuffXfermode”(好吧,其实当时肯定拼错了)几个字,哈哈,终于找到了解决问题的那把key了。于是乎,赶紧上网复习了一下PorterDuffXfermode的相关资料,确定了应该要使用的是SRC_IN的模式,将泡泡图片作为dst,需要显示的图片作为src,保证这两者长宽一致,那合成后就是具有泡泡形状的图片了。想清楚了就直接开始实践,最终完美的达到了胖大叔的要求:
具体实现流程如下:
1 自定义一个继承自ImageView的控件OverlapImageView,因为ImageView里面正好可以配置背景图片(dst)和前景图片(src)。由于我们使用的背景泡泡图是一张.9图片,因此初始化的时候如果背景是.9图,还需要进行一下处理:
final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.OverlapImageView,defStyleAttr,0); boolean isNinePatch = a.getBoolean(R.styleable.OverlapImageView_isNinePatch,false); final int srcResId = a.getResourceId(R.styleable.OverlapImageView_dst, 0); final TypedValue value = new TypedValue(); final Resources r = a.getResources(); try { final InputStream is = r.openRawResource(srcResId, value); final BitmapFactory.Options options = new BitmapFactory.Options(); options.inScreenDensity = (int) r.getDisplayMetrics().scaledDensity; final Rect padding = new Rect(); dstBp = BitmapFactory.decodeResourceStream(r, value, is, padding, options); is.close(); if (isNinePatch && dstBp != null){ mNinePatch = new NinePatch(dstBp,dstBp.getNinePatchChunk(),null); } } catch (IOException e) { // Ignore e.printStackTrace(); Drawable d = a.getDrawable(R.styleable.OverlapImageView_dst); dstBp = drawableToBitmap(d); }
2 复写onDraw()方法:
@Override protected void onDraw(Canvas canvas) { if(srcBp != null && (dstBp != null || mNinePatch != null)){ int width = getRight() - getLeft() > 0 ? getRight() - getLeft() : srcBp.getWidth(); int height = getBottom() - getTop() > 0 ? getBottom() - getTop() :srcBp.getHeight(); //1.创建一个新图层Layer进行效果合成 int sc = canvas.saveLayer(0,0,width,height,null,Canvas.ALL_SAVE_FLAG); Rect r = new Rect(0,0,width,height); //2.绘制DST if (mNinePatch != null){ mNinePatch.draw(canvas,r,mPaint); }else { canvas.drawBitmap(dstBp,null,r,mPaint); } //3.设置混合模式,一旦调用该方法,当前Layer上的内容会被作为DST mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); //4.绘制SRC canvas.drawBitmap(srcBp,null,r,mPaint); mPaint.setXfermode(null); //5.当前Layer退栈,将其内容保存到canvas默认的Layer上 canvas.restoreToCount(sc); }else { super.onDraw(canvas); } }
需要注意的一点就是一旦调用setXfermode()方法后,当前Layer上的内容就会被当做dst的内容进行处理。canvas默认是自带了一个Layer,因此如果没有调用saveLayer(),那当前canvas上的所有内容都是dst了。因此必须搞清楚哪些内容是dst,不然的话合成出来的就可能达不到预期效果了。
另一个栗子
又过了几天,胖大叔又呼哧呼哧的跑过来找我,说是那个粉丝榜的进度条效果不好看,要改!然后他就给我发了一张具有指导性意见的图片,就按这个效果改啊:
有了上次的经验,这个我略微思索,掐指一算,嗯,你小子不就是XOR模式吗,当然,还需要对progressBar进行一下改造,默认的进度条是无法显示文字的。大致的实现思路就是先新建一个Layer,绘制ProgressBar的边框,绘制进度条,然后把这两个图像作为dst,再绘制文字作为src,最后使用XOR模式进行合成,就可以达到上面的效果啦,具体代码如下:
1 新建进度条背景的xml文件:
<?xml version="1.0" encoding="utf-8"?> <layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <item android:id="@android:id/background"> <shape android:shape="rectangle"> <corners android:radius="3dp"></corners> <stroke android:width="1px" android:color="@color/bg_color_edc550"></stroke> </shape> </item> <item android:id="@android:id/progress"> <clip> <shape android:shape="rectangle"> <corners android:radius="3dp"></corners> <solid android:color="@color/bg_color_ffd53a"/> </shape> </clip> </item> </layer-list>
2 自定义一个控件继承ProgressBar,然后复写onDraw()方法:
@Override protected synchronized void onDraw(Canvas canvas) { if (mText.length() == 0){ super.onDraw(canvas); return; }else if (!mRevertMode){ super.onDraw(canvas); drawText(canvas); return; } //1.新建一个图层Layer int sc = canvas.saveLayer(0,0,getMeasuredWidth(),getMeasuredHeight(),null,Canvas.ALL_SAVE_FLAG); //2. 绘制背景边框 final Drawable backgound = getBackground(); if (backgound != null){ backgound.draw(canvas); } //3. 绘制进度条 final Drawable d = getProgressDrawable(); if(d != null){ final int saveCount = canvas.save(); d.draw(canvas); canvas.restoreToCount(saveCount); if (mText.length() > 0){ //4. 设置混合模式XOR, mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.XOR)); //5. 绘制进度文字 drawText(canvas); mPaint.setXfermode(null); } } //6.退栈,将效果合成到canvas中 canvas.restoreToCount(sc); }
最终的效果如下:
TIPS:
假如你设置的混合模式没有生效,试着关闭一下硬件加速功能。
更多网易技术、产品、运营经验分享请点击。
相关文章:
【推荐】 PaaS服务之路漫谈(三)
【推荐】 云计算交互设计师的正确出装姿势