App 黑白化實現探索,有一行程式碼實現的方案嗎?

鴻洋發表於2020-04-04

本文已授權公眾號 hongyangAndroid 原創首發。

4 月 4 日這一天,不少 網站、App 都通過黑白化,表達了深切的哀悼。

這篇文章我們純談技術。

我在當天,也給wanandroid.com上線了黑白化效果:

App 黑白化實現探索,有一行程式碼實現的方案嗎?

大家可能做 app 比較多,網頁端全站實現這一的效果,只需要一句話:

html {filter:progid:DXImageTransform.Microsoft.BasicImage(grayscale=1);-webkit-filter: grayscale(100%);}
複製程式碼

只要給 html 加一句css 樣式就可以了,你可以理解為給整個頁面新增了一個灰度效果。

就完成了,真的很方便。

回頭看 app,大家都覺得開發起來比較麻煩,大家普遍的思路就是:

  1. 換膚;
  2. 展現 server 下發的圖片,還需要單獨做灰度處理;

這麼看起來工作量還是很大的。

後來我就在思考,既然 web 端可以這麼給整個頁面加一個灰度的效果,我們 app 應該也可以呀?

那我們如何給app頁面加一個灰度效果呢?

我們的 app 頁面正常情況下,其實也是 Canvas 繪製出來的對吧?

Canvas 對應的相關 API 肯定也是支援灰度的。

那麼是不是我們在控制元件繪製的時候,比如 draw 之前設定個灰度效果就可以呢?

好像發現了什麼玄機。

1. 嘗試給 ImageView 上個灰度效果

那麼我們首先通過 ImageView 來驗證一下灰度效果的可行性。

我們編寫個自定義的 ImageView,叫做:GrayImageView

佈局檔案是這樣的:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".TestActivity">

    <ImageView
        android:layout_width="100dp"
        android:layout_height="wrap_content"
        android:src="@mipmap/logo">

    </ImageView>

    <com.imooc.imooc_wechat_app.view.GrayImageView
        android:layout_width="100dp"
        android:layout_height="wrap_content"
        android:src="@mipmap/logo" />

</LinearLayout>
複製程式碼

很簡單,我們放了一個 ImageView 用來做對比。

看下 GrayImageView 的程式碼:

public class GrayImageView extends AppCompatImageView {
    private Paint mPaint = new Paint();

    public GrayImageView(Context context, AttributeSet attrs) {
        super(context, attrs);

        ColorMatrix cm = new ColorMatrix();
        cm.setSaturation(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();
    }

}

複製程式碼

在分析程式碼之前,我們看下效果圖:

App 黑白化實現探索,有一行程式碼實現的方案嗎?

很完美,我們成功把 wanandroid圖示搞成了灰色。

看一眼程式碼,程式碼非常簡單,我們複寫了draw 方法,在該方法中給canvas 做了一下特殊處理。

什麼特殊處理呢?其實就是設定了一個灰度效果。

在 App中,我們對於顏色的處理很多時候會採用顏色矩陣,是一個4*5的矩陣,原理是這樣的:

[ a, b, c, d, e,
    f, g, h, i, j,
    k, l, m, n, o,
    p, q, r, s, t ] 
複製程式碼

應用到一個具體的顏色[R, G, B, A]上,最終顏色的計算是這樣的:

R’ = a*R + b*G + c*B + d*A + e;
G’ = f*R + g*G + h*B + i*A + j;
B’ = k*R + l*G + m*B + n*A + o;
A’ = p*R + q*G + r*B + s*A + t;
複製程式碼

是不是看起來很難受,沒錯我也很難受,看到代數就煩。

既然大家都難受,那麼Android 就比較貼心了,給我們搞了個ColorMartrix類,這個類對外提供了很多 API,大家直接呼叫 API 就能得到大部分想要的效果了,除非你有特別特殊的操作,那麼可以自己通過矩陣去運算。

像灰度這樣的效果,我們可以通過飽和度 API來操作:

setSaturation(float sat)
複製程式碼

傳入 0 就可以了,你去看原始碼,底層傳入了一個特定的矩陣去做的運算。

ok,好了,忘掉上面說的,就記得你有個 API 能把 canvas 繪製出來的東西搞成灰的就行了。

那麼我們已經實現了把 ImageView 弄成了灰度,TextView 可以嗎?Button可以嗎?

2. 嘗試舉一反三

我們來試試TextView、Button。

程式碼完全一樣哈,其實就是換了個實現類,例如 GrayTextView:

public class GrayTextView extends AppCompatTextView {
    private Paint mPaint = new Paint();

    public GrayTextView(Context context, AttributeSet attrs) {
        super(context, attrs);

        ColorMatrix cm = new ColorMatrix();
        cm.setSaturation(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();
    }
}
複製程式碼

沒任何區別,GrayButton 就不貼了,我們看佈局檔案:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".TestActivity">

    <ImageView
        android:layout_width="100dp"
        android:layout_height="wrap_content"
        android:src="@mipmap/logo">

    </ImageView>

    <com.imooc.imooc_wechat_app.view.GrayImageView
        android:layout_width="100dp"
        android:layout_height="wrap_content"
        android:src="@mipmap/logo" />

    <TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="鴻洋真帥"
    android:textColor="@android:color/holo_red_light"
    android:textSize="30dp" />


    <com.imooc.imooc_wechat_app.view.GrayTextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="鴻洋真帥"
        android:textColor="@android:color/holo_red_light"
        android:textSize="30dp" />


    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="鴻洋真帥"
        android:textColor="@android:color/holo_red_light"
        android:textSize="30dp" />


    <com.imooc.imooc_wechat_app.view.GrayButton
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="鴻洋真帥"
        android:textColor="@android:color/holo_red_light"
        android:textSize="30dp" />

</LinearLayout>
複製程式碼

對應的效果圖:

App 黑白化實現探索,有一行程式碼實現的方案嗎?

可以看到 TextView,Button 也成功的把紅色的字型換成了灰色。

這個時候你是不是忽然感覺自己會了?

其實我們只要把各種相關的 View 換成這種自定義 View,利用 appcompat換膚那一套,不需要 Server 參與了,客戶端搞搞就行了。

是嗎?我們需要把所有的 View 都換成自定義的 View嗎?

這聽起來成本也挺高呀。

再想想還有更簡單的嗎?

3. 往上看一眼

雖然剛才的佈局檔案很簡單,但是邀請你再去看一眼剛才的佈局檔案,我要問你問題了:

看好了吧。

  1. 請問上面的 xml 中,ImageView的父 View 是誰?
  2. TextView 的父 View 是誰?
  3. Button 的父 View 是誰?

有沒有一點茅塞頓開!

我們需要一個個自定義嗎?

父 View 都是 LinearLayout,我們搞個 GrayLinearLayout 不就行了,其內部的 View 都會變成灰色,畢竟 Canvas 物件是往下傳遞的。

我們來試試:

GrayLinearLayout:

public class GrayLinearLayout extends LinearLayout {
    private Paint mPaint = new Paint();

    public GrayLinearLayout(Context context, AttributeSet attrs) {
        super(context, attrs);

        ColorMatrix cm = new ColorMatrix();
        cm.setSaturation(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();
    }

}
複製程式碼

程式碼很簡單,但是注意有個細節,我們也複寫了 dispatchDraw,為什麼呢?自己思考:

我們更換下 xml:

<?xml version="1.0" encoding="utf-8"?>
<com.imooc.imooc_wechat_app.view.GrayLinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".TestActivity">

    <ImageView
        android:layout_width="100dp"
        android:layout_height="wrap_content"
        android:src="@mipmap/logo" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="鴻洋真帥"
        android:textColor="@android:color/holo_red_light"
        android:textSize="30dp" />

    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="鴻洋真帥"
        android:textColor="@android:color/holo_red_light"
        android:textSize="30dp" />

</com.imooc.imooc_wechat_app.view.GrayLinearLayout>
複製程式碼

我們放了藍色 Logo 的 ImageView,紅色字型的 TextView 和 Button,看一眼效果:

App 黑白化實現探索,有一行程式碼實現的方案嗎?

完美!

是不是又有點茅塞頓開!

只要我們換了 我們設定的Activity 的根佈局就可以了!

Activity 的根佈局可能是 LinearLayout,FrameLayout,RelativeLayout,ConstraintLayout...

換個雞兒...這得換到啥時候,跟剛才有啥區別。

還有思路嗎,沒什麼確定的 View 嗎?

再想想。

我們的設定的 Activity 的根佈局會放在哪?

android.id.content
複製程式碼

是不是這個 Content View 上面?

這個 content view 目前一直是 FrameLayout !

那麼我們只要在生成這個android.id.content 對應的 FrameLayout,換成 GrayFrameLayout 就可以了。

怎麼換呢?

appcompat 那一套?去搞 LayoutFactory?

確實可以哈,但是那樣要設定 LayoutFactory,還需要考慮 appcompat 相關邏輯。

有沒有那種不需要去修改什麼流程的方案?

4. LayoutInflater 中的細節

還真是有的。

我們的 AppCompatActivity,可以複寫 onCreateView 的方法,這個方法其實也是LayoutFactory在構建 View 的時候回撥出來的,一般對應其內部的mPrivateFactory。

他的優先順序低於 Factory、Factory2,相關程式碼:

if (mFactory2 != null) {
    view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
    view = mFactory.onCreateView(name, context, attrs);
} else {
    view = null;
}

if (view == null && mPrivateFactory != null) {
    view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}

if (view == null) {
    final Object lastContext = mConstructorArgs[0];
    mConstructorArgs[0] = context;
    try {
        if (-1 == name.indexOf('.')) {
            view = onCreateView(parent, name, attrs);
        } else {
            view = createView(name, null, attrs);
        }
    } finally {
        mConstructorArgs[0] = lastContext;
    }
}   
複製程式碼

但是目前對於 FrameLayout,appcompat 並沒有特殊處理,也就是說你可以在 onCreateView 回撥中去構造 FrameLayout 物件。

很簡單,就複寫 Activity 的 onCreateView 方法即可:

public class TestActivity extends AppCompatActivity {

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

    @Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        return super.onCreateView(name, context, attrs);
    }
}

複製程式碼

我們在這個方法中把content view 對應的 FrameLayout 換成 GrayFrameLayout.

@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 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;
                }
            }
        }
    }
    return super.onCreateView(name, context, attrs);
}
複製程式碼

程式碼應該都能看明白吧,我們找到 id 是 android:id/content 的,換成了我們的 GrayFrameLayout。

最後看一眼GrayFrameLayout:

public class GrayFrameLayout extends FrameLayout {
    private Paint mPaint = new Paint();

    public GrayFrameLayout(Context context, AttributeSet attrs) {
        super(context, attrs);

        ColorMatrix cm = new ColorMatrix();
        cm.setSaturation(0);
        mPaint.setColorFilter(new ColorMatrixColorFilter(cm));
    }

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


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

}
複製程式碼

好了,執行一下,看下效果:

App 黑白化實現探索,有一行程式碼實現的方案嗎?

效果 ok。

然後把onCreateView 這坨程式碼,放到你的 BaseActivity裡面就行了。

什麼,沒有 BaseActivity?

...

5. 找個 App驗證下

說到現在,都沒有脫離出一個 Activity。

我們找個複雜點的專案驗證下好吧。

我去 github 找個 wanandroid 的 Java 開源專案:

選中了:

github.com/jenly1314/W…

匯入後,只要在 BaseActivity 裡面新增我們剛才的程式碼就可以了。

執行效果圖:

App 黑白化實現探索,有一行程式碼實現的方案嗎?

App 黑白化實現探索,有一行程式碼實現的方案嗎?

App 黑白化實現探索,有一行程式碼實現的方案嗎?

App 黑白化實現探索,有一行程式碼實現的方案嗎?

恩,沒錯,webview 裡面的文字,圖片都黑白化了。

沒發現啥問題,這樣一個 app 就完全黑白化了。

等等,我發現狀態列沒變,狀態列是不是有 API,自己在 BaseActivity 裡面呼叫一行程式碼處理哈。

號內回覆:「文章寫的真好」,獲取黑白化後的 apk,自己體驗。

6. 真的沒問題了嗎?

其實沒執行出來問題有些遺憾。

那我自爆幾個問題吧。

1. 如果 Activity的 Window 設定了 background,咋辦呢?

因為我們處理的是 content view,肯定在 window 之下,肯定覆蓋不到 window 的 backgroud。

咋辦咋辦?

不要慌。

我們生成的GrayFrameLayout也是可以設定 background 的?

if ("android:id/content".equals(idVal)) {
    GrayFrameLayout grayFrameLayout = new GrayFrameLayout(context, attrs);
    grayFrameLayout.setBackgroundDrawable(getWindow().getDecorView().getBackground());
    return grayFrameLayout;
}
複製程式碼

如果你是theme 中設定的 windowBackground,那麼需要從 theme 裡面提取 drawable,參考程式碼如下:

TypedValue a = new TypedValue();
getTheme().resolveAttribute(android.R.attr.windowBackground, a, true);
if (a.type >= TypedValue.TYPE_FIRST_COLOR_INT && a.type <= TypedValue.TYPE_LAST_COLOR_INT) {
    // windowBackground is a color
    int color = a.data;
} else {
    // windowBackground is not a color, probably a drawable
    Drawable c = getResources().getDrawable(a.resourceId);
}
複製程式碼

來源搜尋的 stackoverflow.

2.Dialog 支援嗎?

這個方案預設就已經支援了 Dialog 黑白化,為什麼?自己擼一下 Dialog 相關原始碼,看看 Dialog 內部的 View 結構是什麼樣子的。

另外 webview 內部的圖片文字也支援。

3. 如果 android.R.id.content 不是 FrameLayout 咋辦?

確實有這個可能。

想必你也有辦法把PhoneWindow 的內部 View 搞成這個樣子:

decorView
	GrayFrameLayout
		android.R.id.content
			activity rootView
複製程式碼

或者這個樣子:

decorView
	android.R.id.content
		GrayFrameLayout
			activity rootView
複製程式碼

可以吧。

好了,我要收尾了。

程式碼寫了 3 分鐘,文章寫了一下午。

本文絕不是簡單的說了下黑白化如何實現,因為上來貼程式碼只需要 30 行左右程式碼就結束了。

實際上本文包含 1W 多個字元,希望你能從中獲取到足夠的知識,拜了個拜!

相關文章