又见清明之 app黑白化

2,036 阅读5分钟

背景

​ 疫情已经持续了两年之久,从去年清明节各个APP陆续在节日当天将APP黑白化,以示对逝者的尊敬和祭奠。除了悼念,作为一个开发者,更需要理解这背后的技术即黑白化的技术。


一、带零点技术的方案

​ 其实出于对逝者的尊重,只要APP首页不出现艳色或者明亮色,即将所有艳色的文字和 图片 替换成 暗色系抑或黑白色的即可,但这种方案基本没有可复用性 并且开发成本很大,这种方案其实没用到什么技术,那作为技术人,是否可以考虑下一丁点技术呢?


二、带一点技术的方案

​ 考虑到改变图片 或者 文字颜色,很容易就想到了绘制,也就是和 onDraw 有关,那我们能不能通过自定义view来实现呢 ? 答案当然是可以的 ,我们通过自定义view ,通过复写 onDraw即可将背景改为黑白

public GrayView(@NonNull Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
    ColorMatrix cm = new ColorMatrix();
    cm.setSaturation(0);
    mPaint.setColorFilter(new ColorMatrixColorFilter(cm));
}

public GrayView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
}
@Override
protected void onDraw(Canvas canvas) {
    canvas.saveLayer(null,mPaint,Canvas.ALL_SAVE_FLAG);
    super.onDraw(canvas);
    canvas.restore();
}

​ 我们通过自定义view 通过 ColorMatrix.setSaturation(0) 构建黑白的矩阵,然后通过 mPaint 来绘制达到黑白化效果。为什么ColorMatrix.setSaturation(0) 可以达到黑白化的效果呢?我们看下注释:

/**
 * Set the matrix to affect the saturation of colors.
 *
 * @param sat A value of 0 maps the color to gray-scale. 1 is identity.
 */
public void setSaturation(float sat) {
    reset();
    float[] m = mArray;

    final float invSat = 1 - sat;
    final float R = 0.213f * invSat;
    final float G = 0.715f * invSat;
    final float B = 0.072f * invSat;

    m[0] = R + sat; m[1] = G;       m[2] = B;
    m[5] = R;       m[6] = G + sat; m[7] = B;
    m[10] = R;      m[11] = G;      m[12] = B + sat;
}

注释传入0 即可达到黑白效果,通过修改颜色矩阵,paint绘图达到黑白灰效果。

​ 但是这种方案需要定义很多自定义View,相比第一种没有多少进步性可言,但是至少我们开始用技术了,那么我们有带两点 或者三点 或者更多的技术么?


三、带两点技术的方案

​ 在方案二的基础上,既然所有控件都是view ,自定义所有控件的开发量巨大,那么对于viewGroup 来说如果自定义了viewgroup实现黑白化,paint、canvas又可以通过父VIEW传递给子View,那也就意味着我们只需要替换页面的根布局,只用自定义这个ViewGroup 就行。

关键代码:

@Override
protected void dispatchDraw(Canvas canvas) {
    canvas.saveLayer(null,mPaint,Canvas.ALL_SAVE_FLAG);
    super.dispatchDraw(canvas);
    canvas.restore();
}

我们可以在我们app 中baseActivity 中 替换掉我们自定义的viewGroup 来替换 根布局:

@Nullable
 @Override
 public View onCreateView(String name, Context context, AttributeSet attrs) {
     if("FrameLayout".equals(name)){
         int count = attrs.getAttributeCount();
         for(int i = 0; i < count;i++){
             String attrName = attrs.getAttributeName(i);
             String attrValue = attrs.getAttributeValue(i);
             if(attrName.equals("id")){
                 int id = Integer.parseInt(attrValue.substring(1));
                 String idValue = getResources().getResourceName(id);
                 if("android:id/content".equals(idValue)){
                     GrayFramLayout grayFramLayout = new GrayFramLayout(context,attrs);
                     return grayFramLayout;
                 }

             }


         }
     }
     return super.onCreateView(name, context, attrs);
 }

这个方案我们细想下,有什么缺点么? baseActivity 是APP中的基类,那第三方页面呢,没有继承我们的baseActivity,是不是就可以一直逍遥法外了? 接下来我们再看一种方案。


四、带三点技术的方案

既然我们可以替换android:id/content,那我们是不是也可以替换DecorView

参考代码如下:

参考代码如下:

@Override
protected void onCreate(Bundle savedInstanceState)
{
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    Paint mPaint = new Paint();
    ColorMatrix cm = new ColorMatrix();
    cm.setSaturation(0);
    mPaint.setColorFilter(new ColorMatrixColorFilter(cm));
    getWindow().getDecorView().setLayerType(View.LAYER_TYPE_HARDWARE,mPaint);
    initView();
}

上述代码经过测试,对于基类是AppCompatActivity 的页面不生效,似乎对上一种方案又有了倒退,谷歌中途对于AppCompatActivity 采用setFactory 做了一层转换,那既然提到了setFactory,那我们是不是也可以在这个上边做做文章?


五、终极解决方案

既然方案3 和 方案4 各有千秋,那各取所长是不是就可以达到一种完美的解决方案,答案是确定的 。另外我们再加上 lifeCycleCallback ,就可以达到所有的 Activity 都生效。

主要代码:

Application.ActivityLifecycleCallbacks() {
    @Override
 public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
       
 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity.getWindow() != null
 && activity.getWindow().getDecorView() != null) {
            setViewGray(activity.getWindow().getDecorView());
        }
        LayoutInflater layoutInflater = activity.getLayoutInflater();
        if (layoutInflater == null) {
            return;
        }
        // 获取 是否设置过factory
 boolean isFactorySet = getFactorySet(layoutInflater);
        if (isFactorySet) {
            // 设置mFactorySet
 setFactorySet(layoutInflater);
            // 重新读mFactorySet
 isFactorySet = getFactorySet(layoutInflater);
        }
        if (!isFactorySet) {
            try {
                LayoutInflaterCompat.setFactory2(activity.getLayoutInflater(), new LayoutInflater.Factory2() {
                    @Override
 public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
                        
           
 if ("FrameLayout".equals(name)) {
                            int count = attrs.getAttributeCount();
                            for (int i = 0; i < count; i++) {
                                String attrName = attrs.getAttributeName(i);
                                String attrValue = attrs.getAttributeValue(i);
                                if (attrName.equals("id")) {
                                    int id = Integer.parseInt(attrValue.substring(1));
                                    String idValue = context.getResources().getResourceName(id);
                                    if ("android:id/content".equals(idValue)) {
                                        FrameLayout grayFrameLayout = new FrameLayout(context, attrs);
                                        setViewGray(grayFrameLayout);
                                        return grayFrameLayout;
                                    }
                                }
                            }
                        }
                        return null;
                    }
                    
                    @Override
 public View onCreateView(String name, Context context, AttributeSet attrs) {
                        
                        return null;
                    }
                });
            } catch (Exception e) {
                WLog.e(TAG, e.getMessage());
            }
            
        }
        
    }

第一层通过设置decordView 达到黑白化,第二层通过setFactory2 来替换根布局来达到黑白化。


六、关于黑白化不可逆的原理

上述方案可以将页面黑白化,但黑白化的页面,想通过颜色矩阵显示正常,这个臣妾办不到呀!为什么呢?

我们先看下原理:

灰度图片的去色原理:只要把 RGB 的三色通道的数值设置为一样,即 R=G=B,那么图像就变成了灰色,同时为了 保证图像的亮度,需要使同一个通道中的 R+G+B 的结果接近1。

  • 在 matlab 中按照 0.2989 R,0.5870 G 和 0.1140 B 的比例构成像素灰度值
  • 在 OpenCV 中按照 0.299 R, 0.587 G 和 0.114 B 的比例构成像素灰度值
  • 在 Android 中按照0.213 R,0.715 G 和 0.072 B 的比例构成像素灰度值

按照如上原理,要求运算后:

R'=G‘=B' = 0.213R+0.715G+0.072B 即可实现黑白化

于是得出如下矩阵:

[ 0.213f 0.715f 0.072f 0 0   
  0.213f 0.715f 0.072f 0 0   
  0.213f 0.715f 0.072f 0 0   
  0 0 0 1 0 ] 

与颜色分量矩阵相乘后即可黑白化成功,源码setSaturation(0f)时得到的矩阵和上述一致。

我们先假设是可逆的,即可以恢复部分区域正常:

黑白化后 :

R'=G‘=B' = 0.213R+0.715G+0.072B ,及新的颜色分量矩阵 C':

[ R'
  G'
  B'
  A'
  1]

假设是可逆的,我们需要先假设一个矩阵M'如下:

[  x, y,  z,  0,  0,
   a, b, c, 0, 0,
   d, e, f, 0, 0,
   0, 0,  0,  1,  0 ]

需要和新的颜色分量矩阵运算后 及 M' * C' 需要得到结果:R、G、B、A :

可以得到方程式:

方程式一:(x+y+z)*0.213R + (x+y+z)*0.715G + (x+y+z)*0.072B = R

方程式二:(a+b+c)*0.213R + (a+b+c)*0.715G + (a+b+c)*0.072B = G

方程式三:(d+e+f)*0.213R + (d+e+f)*0.715G + (d+e+f)*0.072B = B

我们以方程式一来举例:

以R来举例:需要x+y+z = 1/0.213;

以R的G分量来举例:需要x+y+z = 0;

综上两个可以知道 x,y,z 是无解的(a,b,c / d,e,f)同理。

由于x/y/z 处于无解状态,所以局部恢复难以实现,这也是上述方案的缺点