背景:
4月4日,国家为表达全国各族人民对抗击新冠肺炎疫情斗争牺牲烈士和逝世同胞的深切哀悼,举行全国性哀悼活动,各大网站和App也都变成了灰色。
从宣布全国哀悼日到4号,期间也就1、2天时间,主流网站还是快速做出了响应,虽然这次我们公司没有跟进把网站和App置灰,但实现方案也可以作为一种技术储备,故有了这篇文章。
网站实现方案:
以管理后台为例,所有页面都包裹在一层html中,只要在html中加入一个灰度处理,就应该能实现效果。具体操作如下:
可以说是很简单了,我们看看实际的效果如何:
效果很完美,首页和子页面都能很好的实现置灰。
App实现方案:
App的每个页面布局都是一个单独的XML文件,没有像H5那样有一个公共的XML布局可以修改达到效果。
但我们可以试着从一个ImageView开始,让它变成灰色看看。
我们先新建一个CustomImageView:
了解自定义View的绘制过程的都应该知道,View的绘制主要就是3个方法:onMeasure
、onLayout
、onDraw
分别是测量、计算位置、绘制,我们想要把view置灰,想来应该要从onDraw
入手,但这次,我们不是使用这个onDraw
,为什么呢?我们看下另外一个绘制方法draw
,源码上的注释是这么写的:
public void draw(Canvas canvas) {
final int privateFlags = mPrivateFlags;
mPrivateFlags = (privateFlags & ~PFLAG_DIRTY_MASK) | PFLAG_DRAWN;
/*
* Draw traversal performs several drawing steps which must be executed
* in the appropriate order:
*
* 1. Draw the background // 画背景
* 2. If necessary, save the canvas' layers to prepare for fading
* 3. Draw view's content // 绘制内容
* 4. Draw children // 绘制子视图
* 5. If necessary, draw the fading edges and restore layers
* 6. Draw decorations (scrollbars for instance) // 绘制装饰。主要是foreground与滚动条
*/
// Step 1, draw the background, if needed
int saveCount;
drawBackground(canvas);
// skip step 2 & 5 if possible (common case)
final int viewFlags = mViewFlags;
boolean horizontalEdges = (viewFlags & FADING_EDGE_HORIZONTAL) != 0;
boolean verticalEdges = (viewFlags & FADING_EDGE_VERTICAL) != 0;
if (!verticalEdges && !horizontalEdges) {
// Step 3, draw the content
onDraw(canvas);
// Step 4, draw the children
dispatchDraw(canvas);
drawAutofilledHighlight(canvas);
// Overlay is part of the content and draws beneath Foreground
if (mOverlay != null && !mOverlay.isEmpty()) {
mOverlay.getOverlayView().dispatchDraw(canvas);
}
// Step 6, draw decorations (foreground, scrollbars)
onDrawForeground(canvas);
// Step 7, draw the default focus highlight
drawDefaultFocusHighlight(canvas);
if (debugDraw()) {
debugDrawFocus(canvas);
}
// we're done...
return;
}
Step 3, draw the content
说了onDraw
只绘制了content
,一般我们自定义View,google推荐用onDraw
就足够了,但这次我们要改变所有内容的颜色饱和度,那肯定要用draw
方法。
在draw
的上下文有个canvas
对象,再进入canvas
类看下,里面有各种各样的绘制方法,drawBitmap
、drawText
等等,这些方法有个共同点,就是都会传入一个paint
对象,这个对象就是画笔,我们要想显示置灰,就要从这个画笔入手。
@Override
public void draw(Canvas canvas) {
super.draw(canvas);
}
public void drawBitmap(@NonNull Bitmap bitmap, @Nullable Rect src, @NonNull Rect dst,
@Nullable Paint paint) {
super.drawBitmap(bitmap, src, dst, paint);
}
public void drawText(@NonNull char[] text, int index, int count, float x, float y,
@NonNull Paint paint) {
super.drawText(text, index, count, x, y, paint);
}
我们通过查资料找到可以通过设置ColorMatrix
的色彩饱和度就能实现置灰,具体代码如下:
ColorMatrix cm = new ColorMatrix();
cm.setSaturation(0); // 设置色彩饱和度为0
mPaint.setColorFilter(new ColorMatrixColorFilter(cm));
设置完画笔后,就把画笔传入图层中:
@Override
public void draw(Canvas canvas) {
canvas.saveLayer(null, mPaint, Canvas.ALL_SAVE_FLAG);
super.draw(canvas);
canvas.restore();
}
我们运行下代码,验证下我们的设想:
我们用一个普通的
ImageView
做对比,自定义的ImageView如我们预想的那样,相应的TextView、EditText等也能实现同样的效果,接下去我们就会想,我们把这段代码作用在这些View的父标签上面,是不是会把里面所有的子控件一起置灰,这里我们拿LinearLayout尝试下,代码如下:
public class GreyLinearLayout extends LinearLayout {
private Paint mPaint = new Paint();
public GreyLinearLayout(Context context, AttributeSet attrs) {
super(context, attrs);
ColorMatrix cm = new ColorMatrix();
cm.setSaturation(0); // 设置色彩饱和度为0
mPaint.setColorFilter(new ColorMatrixColorFilter(cm));
}
@Override
public void draw(Canvas canvas) {
canvas.saveLayer(null, mPaint, Canvas.ALL_SAVE_FLAG);
super.draw(canvas);
canvas.restore();
}
@Override
protected void dispatchDraw(Canvas canvas) {
canvas.saveLayer(null, mPaint, Canvas.ALL_SAVE_FLAG);
super.dispatchDraw(canvas);
canvas.restore();
}
}
这里比ImageView
多覆盖了一个方法dispatchDraw
,具体原因就是当DecorView
绘制完自己以后,会调用drawChild(canvas, child, drawingTime)
依次绘制子View,当LinearLayout
没有背景,就会跳过draw
方法,直接调用dispatchDraw
,所以必须覆盖2个方法。子View绘制代码如下:
/**
* This method is called by ViewGroup.drawChild() to have each child view draw itself.
* 父容器分发,调用子View绘制自己
* This is where the View specializes rendering behavior based on layer type,
* and hardware acceleration.
*/
boolean draw(Canvas canvas, ViewGroup parent, long drawingTime) {
......
// Fast path for layouts with no backgrounds
// 没有背景时候的快速路径
if ((mPrivateFlags & PFLAG_SKIP_DRAW) == PFLAG_SKIP_DRAW) {
mPrivateFlags &= ~PFLAG_DIRTY_MASK;
dispatchDraw(canvas);
} else {
draw(canvas);
}
......
}
到这一步,如果我们要想实现效果,只要把所有的xml布局的最外层标签都换成各种GreyLinearLayout
、GreyRelativeLayout
等等就行了,但这样工作量依旧很大,并不是我们想要的快速实现方案。那有没有一个ViewGroup
是这些所有的xml布局的父标签呢,答案我们慢慢来寻找。我们先看下Activity在启动时,首先调用的onCreate
,接下去看下源码:
/**
* Set the activity content from a layout resource. The resource will be
* inflated, adding all top-level views to the activity.
*
* @param layoutResID Resource ID to be inflated.
*
* @see #setContentView(android.view.View)
* @see #setContentView(android.view.View, android.view.ViewGroup.LayoutParams)
*/
public void setContentView(@LayoutRes int layoutResID) {
getWindow().setContentView(layoutResID);
initWindowDecorActionBar();
}
这里的getWindow()
,是Android为Window
提供了的唯一实现类PhoneWindow
,我们接下去看下PhoneWindow
的setContentView
方法
public static final int ID_ANDROID_CONTENT = com.android.internal.R.id.content;
public PhoneWindow(Context context) {
super(context);
mLayoutInflater = LayoutInflater.from(context);
}
@Override
public void setContentView(int layoutResID) {
.....
if (mContentParent == null) {
installDecor(); // 初始化了DecoView
}
mLayoutInflater.inflate(layoutResID, mContentParent);
.....
}
private void installDecor() {
ViewGroup contentParent = (ViewGroup)findViewById(ID_ANDROID_CONTENT);
}
从上面的代码我们可以看到我们自己写的xml布局塞在R.id.content
这个里面,那我们把这个ViewGroup
样式置灰是不是就达到了我们想要的结果。但是我们哪里去处理这个动作呢,我们只能接着看mLayoutInflater.inflate
源码。下面列出部分关键逻辑代码:
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
synchronized (mConstructorArgs) {
......
try {
......
final String name = parser.getName();
if (TAG_MERGE.equals(name)) {
rInflate(parser, root, inflaterContext, attrs, false);
} else {
// 拿到root view
// Temp is the root view that was found in the xml
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
......
// 拿到所有child view
// Inflate all children under temp against its context.
rInflateChildren(parser, temp, attrs, true);
}
return result;
}
}
void rInflate(XmlPullParser parser, View parent, Context context,
AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {
.....
// 通过xml解析器,遍历所有的布局节点
while (((type = parser.next()) != XmlPullParser.END_TAG ||
parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {
final String name = parser.getName();
if (TAG_REQUEST_FOCUS.equals(name)) {
pendingRequestFocus = true;
consumeChildElements(parser);
} else if (TAG_TAG.equals(name)) {
parseViewTag(parser, parent, attrs);
} else if (TAG_INCLUDE.equals(name)) {
if (parser.getDepth() == 0) {
throw new InflateException(" cannot be the root element");
}
parseInclude(parser, context, parent, attrs);
} else if (TAG_MERGE.equals(name)) {
throw new InflateException(" must be the root element");
} else {
// 最终和root view一样,也是调用createViewFromTag去生成View
final View view = createViewFromTag(parent, name, context, attrs);
final ViewGroup viewGroup = (ViewGroup) parent;
final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
rInflateChildren(parser, view, attrs, true);
viewGroup.addView(view, params);
}
}
......
}
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
......
try {
View view;
// 1
if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
// 2
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
// 3
if (view == null && mPrivateFactory != null) {
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}
// 4
if (view == null) {
......
}
return view;
}
}
从上面的代码可以看到,不管是root view还是自己xml的子view,都是通过createViewFromTag方法里面,按mFactory2
、mFactory
、mPrivateFactory
, 分4步去尝试拿到当前遍历的view,mFactory2
回调的就是activity的onCreateView
,那我们覆写下activity的onCreateView
方法,拿到view以后就不会执行其他factory
了,就达到了我们想要替换的目标。让我们试试看,写之前我们通过android sdk
自带的布局检查器Layout Inspector
看下我们xml的布局样式:
可以看到我们自定义View
GreyLinearLayout
的父标签是一个id为
content
的
FrameLayout
,跟上面我们源码里面看到的情况一致,然后我们就要自定义个FrameLayout去替换它,代码如下:
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
try {
if ("FrameLayout".equals(name)) {
int count = attrs.getAttributeCount();
for (int i = 0; i < count; i++) {
String attributeName = attrs.getAttributeName(i);
String attributeValue = attrs.getAttributeValue(i);
if (attributeName.equals("id")) {
int id = Integer.parseInt(attributeValue.substring(1));
String idVal = getResources().getResourceName(id);
if ("android:id/content".equals(idVal)) {
GrayFrameLayout grayFrameLayout = new GrayFrameLayout(context, attrs);
return grayFrameLayout;
}
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
return super.onCreateView(name, context, attrs);
}
代码运行下,效果就是我们想要实现的效果,至此,我们通过改动20来行代码实现了整个App的置灰效果,通过热更新等方式,以最小的代码改动,达到了先前我们快速实现样式置灰的效果。