本文已授權公眾號 hongyangAndroid 原創首發。
4 月 4 日這一天,不少 網站、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可以嗎?
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>
複製程式碼
對應的效果圖:
可以看到 TextView,Button 也成功的把紅色的字型換成了灰色。
這個時候你是不是忽然感覺自己會了?
其實我們只要把各種相關的 View 換成這種自定義 View,利用 appcompat換膚那一套,不需要 Server 參與了,客戶端搞搞就行了。
是嗎?我們需要把所有的 View 都換成自定義的 View嗎?
這聽起來成本也挺高呀。
再想想還有更簡單的嗎?
3. 往上看一眼
雖然剛才的佈局檔案很簡單,但是邀請你再去看一眼剛才的佈局檔案,我要問你問題了:
看好了吧。
- 請問上面的 xml 中,ImageView的父 View 是誰?
- TextView 的父 View 是誰?
- 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 相關邏輯。
有沒有那種不需要去修改什麼流程的方案?
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();
}
}
複製程式碼
好了,執行一下,看下效果:
效果 ok。
然後把onCreateView 這坨程式碼,放到你的 BaseActivity裡面就行了。
什麼,沒有 BaseActivity?
...
5. 找個 App驗證下
說到現在,都沒有脫離出一個 Activity。
我們找個複雜點的專案驗證下好吧。
我去 github 找個 wanandroid 的 Java 開源專案:
選中了:
匯入後,只要在 BaseActivity 裡面新增我們剛才的程式碼就可以了。
執行效果圖:
恩,沒錯,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 多個字元,希望你能從中獲取到足夠的知識,拜了個拜!