Android App 的“黑白化”,有一行代碼實現的方案嗎?

前言

清明節是中國傳統的節日。去年的這一天,不少 網站、App 都通過黑白化,表達了深切的哀悼。

當然,在這裏我們只談技術——Android App 黑白化實現探索。

今天和大家分享的是鴻洋大佬的實現方案。

原文地址:鴻洋

掌握了這個方法,清明節換個黑白,國慶節換個大紅……翻身產品經理,緊跟黨的領導,實現中國夢都不在話下!

正文

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

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

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

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

就完成了,真的很方便。

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

  • 換膚;

  • 展現 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();
    }

}

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

很完美,我們成功把 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可以嗎?

嘗試舉一反三

我們來試試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>

對應的效果圖:

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

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

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

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

這聽起來成本也挺高呀。

再想想還有更簡單的嗎?

往上看一眼

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

看好了吧。

  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,看一眼效果:

完美!

是不是又有點茅塞頓開!

只要我們換了 我們設置的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 相關邏輯。

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

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();
    }

}

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

效果 ok。

然後把onCreateView 這坨代碼,放到你的 BaseActivity裏面就行了。

什麼,沒有 BaseActivity?

...自己玩去吧。

找個 App驗證下

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

我們找個複雜點的項目驗證下好吧。

我去 github 找個 wanandroid 的 Java 開源項目:

選中了:https://github.com/jenly1314/WanAndroid

導入後,只要在 BaseActivity 裏面添加我們剛纔的代碼就可以了。

運行效果圖:

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

這樣一個 app 就完全黑白化了。

等等,我發現狀態欄沒變,狀態欄是不是有 API,自己在 BaseActivity 裏面調用一行代碼處理哈。

點擊此處回覆:「文章寫的真好」,可以獲取黑白化後的 apk,自己體驗。

真的沒問題了嗎?

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

那我自爆幾個問題吧。

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);
}

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

可以吧。

好了,我要收尾了,算一行代碼實現嗎?不算,好像有 30 來行的代碼,不過足夠簡單了。

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

最後

本文絕不是簡單的說了下黑白化如何實現,因爲上來貼代碼只需要 30 行左右代碼就結束了。實際上本文包含 1W 多個字符,希望你能從中獲取到足夠的知識。

Android學習是一條漫長的道路,我們要學習的東西不僅僅只有表面的 技術,還要深入底層,弄明白下面的 原理,只有這樣,我們才能夠提高自己的競爭力,在當今這個競爭激烈的世界裏立足。

千里之行始於足下,願你我共勉。

我把自己這段時間整理的Android最重要最熱門的學習方向資料放在了我的GitHub,裏面還有不同方向的自學編程路線、面試題集合/面經、及系列技術文章等。

資源持續更新中,歡迎大家一起學習和探討。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章