自定義View之指南針(反編譯別人的程式碼實現)
一、說明
偶爾點開魅族手機內建的工具箱應用,發現其指南針做的還不錯,就想模擬做一個類似的效果,在這裡我們不準備自己從頭開始編寫程式碼,而是採用一點黑科技,首先,我們從魅族系統中匯出工具箱應用的apk,然後反編譯apk,結合hierarchy view分析其程式碼實現,所以本篇文章會設計到反編譯和自定義view兩方面的知識。
二、介面初步分析
首先看一下魅族工具箱指南針的效果截圖:
當轉動手機時,介面上的指南針會跟隨轉動,魅族的這個指南針效果繪製的還是相當不錯的,做轉動動畫的時候很流暢,感覺不到卡頓現象。首先我們自己來分析一下上面效果的實現:
1、介面上指南針的變化是根據手機方向的改變而變化的,這裡肯定會用到方向感測器。
2、當方向感測器監聽到了方向變化後,需要根據變化的引數來重新整理介面,這裡指南針的部分應該是一個自定義View。
用hierarchy view觀察介面佈局,如下:
可以很明顯的看到指南針部分的實現是一個自定義view,名稱為Compass。這裡說明一下,我們在分析別人的程式碼前,首先應該根據實現效果先大致分析一下其實現原理,這樣不僅對分析別人的程式碼有幫助,而且也可以加深印象,看自己的實現和別人的實現對比有哪些優缺點。
三、反編譯Apk檢視程式碼實現
1、連線魅族手機,通過adb命令匯出工具箱apk
一般系統內建應用都在手機的/system/app目錄下,我們通過hierarchy view可以知道工具箱應用的包名:
包名為"com.meizu.flyme.toolbox",在cmd中輸入以下命令進入手機/system/app目錄:
輸入"ls"命令檢視/system/app的目錄結構 :
這個目錄下面存放了系統內建的App,比如"AlarmClock"為鬧鐘應用,"AppCenter"為應用中心,可以發現,其中有一個名稱為ToolBox的資料夾,猜想其應該是存放工具箱apk的資料夾,進入ToolBox資料夾,檢視其目錄結構,如下:
其中只有一個檔案,為ToolBox.apk,看名稱就知道是工具箱應用的apk,通過adb pull命令將其匯出到電腦中:
這個時候我們就將手機裡面內建應用的apk匯出到電腦上啦:
2、使用反編譯工具反編譯apk
反編譯工具有很多,這裡推薦使用jadx,jadx反編譯apk非常簡單,基本不用我們進行任何操作,直接開啟apk即可:
解壓後點擊bin目錄下的jadx-gui.bat檔案,可以直接開啟jadx的介面:
點選File-->Open file,選擇對應的apk即可完成反編譯。
四、程式碼檢視
之前我們通過hierarchy view知道,指南針介面對應的Activity為“CompassActivity”,在jadx中搜索“CompassActivity”類,操作方式為
點選Navigation-->Class serach,會彈出一個彈框,輸入對應的類名即可:
點選開啟“CompassActivity”類,檢視其程式碼:
發現這個類是沒有經過混淆的,只要經過一些修改就可以之間使用了,並且程式碼基本都能看懂,就算不直接用它的程式碼,也能給我們提供實現的思路。這裡,我就直接用它的程式碼了,經過修改儘量讓程式碼執行起來。修改之後的程式碼如下:
1、CompassActivity
package com.liunian.androidbasic.compass;
import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.view.animation.AccelerateInterpolator;
import com.liunian.androidbasic.R;
import static android.hardware.Sensor.TYPE_ORIENTATION;
public class CompassActivity extends AppCompatActivity {
private Compass mCompassView; // 自定義指南針View,用來繪製指南針
private float mCurrentDirection; // 當前方向
private AccelerateInterpolator mInterpolator; // 轉動指南針時使用的插值器
private float mLastDirection;
private Sensor mOrientationSensor; // 方向感測器
private MZSensorEventListener mSensorListener; // 方向感測器監聽物件
private SensorManager mSensorManager; // 感測器管理物件
private boolean mStopDrawing = false; // 記錄是否重新整理介面,當介面可見的時候才重新整理介面
private float mTargetDirection; // 目標方向
// 方向感測器監聽類
private class MZSensorEventListener implements SensorEventListener {
private MZSensorEventListener() {
}
public void onSensorChanged(SensorEvent sensorEvent) {
int type = sensorEvent.sensor.getType();
if (type == TYPE_ORIENTATION) { // 如果是方向變化了
CompassActivity.this.mTargetDirection = CompassActivity.this.normalizeDegree(sensorEvent.values[0]); // 獲得目標方向
if (CompassActivity.this.mCompassView != null && !CompassActivity.this.mStopDrawing) {
float targetDirection = CompassActivity.this.mTargetDirection;
// 去除無用的轉動
if (targetDirection - CompassActivity.this.mCurrentDirection > 180.0f) {
targetDirection -= 360.0f;
} else if (targetDirection - CompassActivity.this.mCurrentDirection < -180.0f) {
targetDirection += 360.0f;
}
float directionInv = targetDirection - CompassActivity.this.mCurrentDirection; // 計算需要轉動的間隔
float directionPre = directionInv;
if (Math.abs(directionPre) > 0.1f) {
directionPre = directionPre > 0.0f ? 0.1f : -0.1f;
}
CompassActivity.this.mCurrentDirection = CompassActivity.this.normalizeDegree((CompassActivity.this.mInterpolator.getInterpolation(
Math.abs(directionPre) >= 0.1f ? 0.4f : 0.3f) * (directionInv)) + CompassActivity.this.mCurrentDirection); // 這裡採用加速插值器,讓轉動看起來更加流暢
if (((double) Math.abs(CompassActivity.this.mLastDirection - CompassActivity.this.mCurrentDirection)) > 0.05d) { // 如果需要轉動的角度大於0.05,則重新整理介面更新UI
CompassActivity.this.mCompassView.a(CompassActivity.this.mCurrentDirection);
CompassActivity.this.mLastDirection = CompassActivity.this.mCurrentDirection;
}
}
}
}
public void onAccuracyChanged(Sensor sensor, int i) {
}
}
protected void onCreate(Bundle bundle) {
super.onCreate(bundle);
setContentView(R.layout.activity_compass);
getWindow().setBackgroundDrawable(null); // 去除視窗預設的背景色,可以減少一層繪製,提高繪製效率
init();
}
private void init() {
this.mCurrentDirection = 0.0f;
this.mTargetDirection = 0.0f;
this.mStopDrawing = true;
this.mInterpolator = new AccelerateInterpolator();
this.mCompassView = (Compass) findViewById(R.id.compass);
this.mSensorListener = new MZSensorEventListener();
this.mSensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE);
this.mOrientationSensor = this.mSensorManager.getDefaultSensor(TYPE_ORIENTATION);
}
protected void onResume() {
super.onResume();
if (this.mOrientationSensor != null) {
this.mSensorManager.registerListener(this.mSensorListener, this.mOrientationSensor, 0);
}
this.mStopDrawing = false;
}
protected void onPause() {
super.onPause();
this.mStopDrawing = true;
if (!(this.mOrientationSensor == null)) {
this.mSensorManager.unregisterListener(this.mSensorListener);
}
}
// 處理感測器傳過來方向的方法,確保方向引數總在0-360度之間
private float normalizeDegree(float f) {
return (f + 360.0f) % 360.0f;
}
protected void onDestroy() {
super.onDestroy();
}
}
2、Compass
package com.liunian.androidbasic.compass;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.text.TextPaint;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.View;
import android.view.accessibility.AccessibilityEvent;
import com.liunian.androidbasic.R;
import java.text.DecimalFormat;
public class Compass extends View {
private static final DecimalFormat a = new DecimalFormat("##0°");
private static final String[] e = new String[4];
private static final String[] f = new String[12];
private int b;
private float c;
private String d;
private String g;
private Paint h;
private Paint i;
private Paint j;
private Paint k;
private Paint l;
private Drawable m;
private String n;
private String o;
private String p;
private String q;
private String r;
private String s;
private String t;
private String u;
private Drawable v;
private int w;
private TextPaint x;
static {
for (int i = 0; i < 4; i++) {
f[i] = " " + (i * 90) + "°";
}
}
public Compass(Context context) {
this(context, null);
}
public Compass(Context context, AttributeSet attributeSet) {
this(context, attributeSet, 0);
}
public Compass(Context context, AttributeSet attributeSet, int i) {
super(context, attributeSet, i);
this.d = "";
this.g = "";
a();
}
protected void onSizeChanged(int i, int i2, int i3, int i4) {
super.onSizeChanged(i, i2, i3, i4);
b();
}
private void a() {
Resources resources = getResources();
e[0] = resources.getString(R.string.direction_north);
e[1] = resources.getString(R.string.direction_east);
e[2] = resources.getString(R.string.direction_south);
e[3] = resources.getString(R.string.direction_west);
this.n = resources.getString(R.string.direction_due_west);
this.o = resources.getString(R.string.direction_due_east);
this.p = resources.getString(R.string.direction_due_north);
this.q = resources.getString(R.string.direction_due_south);
this.r = resources.getString(R.string.direction_north_east);
this.s = resources.getString(R.string.direction_north_west);
this.t = resources.getString(R.string.direction_south_east);
this.u = resources.getString(R.string.direction_south_west);
}
private void b() {
this.b = getWidth() / 2;
this.h = new Paint();
this.h.setTextSize(c(28.0f));
this.h.setAntiAlias(true);
this.h.setColor(-1);
this.h.setTypeface(Typeface.create("sans-serif-medium", 0));
this.i = new Paint();
this.i.setTextSize(c(14.0f));
this.i.setAntiAlias(true);
this.i.setColor(0x80FFFFFF);
this.j = new Paint();
this.j.setTextSize(c(18.0f));
this.j.setAntiAlias(true);
this.k = new Paint();
this.k.setTextSize(c(16.0f));
this.k.setAntiAlias(true);
this.k.setColor(16777215);
this.l = new Paint();
this.x = new TextPaint();
this.x.setARGB(76, 255, 255, 255);
this.x.setAntiAlias(true);
this.x.setTextSize(c(12.0f));
this.m = getResources().getDrawable(R.mipmap.compass_boundary);
this.m.setBounds(0, 0, this.m.getIntrinsicWidth(), this.m.getIntrinsicHeight());
this.v = getResources().getDrawable(R.mipmap.compass_reference);
this.v.setBounds(0, 0, this.v.getIntrinsicWidth(), this.v.getIntrinsicHeight());
this.w = getResources().getDimensionPixelOffset(R.dimen.compass_content_margin_top) + (this.m.getIntrinsicHeight() / 2);
}
public void a(float f) {
this.c = f;
this.d = a.format((double) this.c);
d(f);
postInvalidate();
}
private void d(float f) {
if (f >= 355.0f || f < 5.0f) {
this.g = this.p;
} else if (f >= 5.0f && f < 85.0f) {
this.g = this.r;
} else if (f >= 85.0f && f <= 95.0f) {
this.g = this.o;
} else if (f >= 95.0f && f < 175.0f) {
this.g = this.t;
} else if (f >= 175.0f && f <= 185.0f) {
this.g = this.q;
} else if (f >= 185.0f && f < 265.0f) {
this.g = this.u;
} else if (f >= 265.0f && f < 275.0f) {
this.g = this.n;
} else if (f >= 275.0f && f < 355.0f) {
this.g = this.s;
}
}
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
this.w = getResources().getDimensionPixelOffset(R.dimen.compass_content_margin_top_with_pressure) + (this.m.getIntrinsicHeight() / 2);
float intrinsicHeight = (float) (this.w - (this.m.getIntrinsicHeight() / 2));
canvas.save();
canvas.translate((float) (this.b - (this.v.getIntrinsicWidth() / 2)), (float) (this.w - (this.v.getIntrinsicHeight() / 2)));
this.v.draw(canvas);
canvas.restore();
canvas.save();
canvas.rotate(-this.c, (float) this.b, (float) this.w);
canvas.translate((float) (this.b - (this.m.getIntrinsicWidth() / 2)), (float) (this.w - (this.m.getIntrinsicHeight() / 2)));
this.m.draw(canvas);
canvas.restore();
canvas.save();
float descent = (((this.j.descent() - this.j.ascent()) / 2.0f) * 2.0f) - this.j.descent();
int i = 0;
while (i < 4) {
this.j.setColor(i == 0 ? 0xFFF15238 : -1);
float measureText = this.j.measureText(e[i]);
canvas.rotate((-this.c) + ((float) (i * 90)), (float) this.b, (float) this.w);
canvas.drawText(e[i], ((float) this.b) - (measureText / 2.0f), (b(39.0f) + intrinsicHeight) + descent, this.j);
canvas.rotate(-1.0f * ((-this.c) + ((float) (i * 90))), (float) this.b, (float) this.w);
i++;
}
canvas.restore();
canvas.drawText(this.d, ((float) this.b) - (this.h.measureText(this.d) / 2.0f), (((((this.h.descent() - this.h.ascent()) / 2.0f) * 2.0f) - this.h.descent()) + b(130.0f)) + intrinsicHeight, this.h);
canvas.drawText(this.g, ((float) this.b) - (this.i.measureText(this.g) / 2.0f), ((((this.i.descent() - this.i.ascent()) / 2.0f) * 2.0f) - this.i.descent()) + (intrinsicHeight + b(162.0f)), this.i);
}
public float b(float f) {
return TypedValue.applyDimension(1, f, getResources().getDisplayMetrics());
}
public float c(float f) {
return TypedValue.applyDimension(2, f, getResources().getDisplayMetrics());
}
public void onInitializeAccessibilityEvent(AccessibilityEvent accessibilityEvent) {
if (accessibilityEvent.getEventType() == 128) {
setContentDescription(this.g + "," + this.d);
}
super.onInitializeAccessibilityEvent(accessibilityEvent);
}
}
3、XML佈局
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black"
tools:context="com.liunian.androidbasic.compass.CompassActivity">
<com.liunian.androidbasic.compass.Compass
android:id="@+id/compass"
android:layout_width="match_parent"
android:layout_height="450dp"/>
</LinearLayout>
引用的strings
<string name="direction_due_east">正東</string>
<string name="direction_due_north">正北</string>
<string name="direction_due_south">正南</string>
<string name="direction_due_west">正西</string>
<string name="direction_east">東</string>
<string name="direction_north">北</string>
<string name="direction_north_east">東北</string>
<string name="direction_north_west">西北</string>
<string name="direction_south">南</string>
<string name="direction_south_east">東南</string>
<string name="direction_south_west">西南</string>
<string name="direction_west">西</string>
引用的dimens
<dimen name="compass_content_margin_top">142dp</dimen>
<dimen name="compass_content_margin_top_with_pressure">100dp</dimen>
4、執行效果
5、核心程式碼分析
經過分析,指南針的思路主要是處理兩個問題:
1、介面上指南針的變化是根據手機方向的改變而變化的,這裡肯定會用到方向感測器。
2、當方向感測器監聽到了方向變化後,需要根據變化的引數來重新整理介面,這裡指南針的部分應該是一個自定義View。
這其中為了優化體驗效果,讓指南針轉動的看起來更加流暢,在更新UI介面時會使用到插值器。上面的指南針自定義View控制元件的程式碼是經過混淆的,雖然經過混淆,但是可以正常執行,並且程式碼應該大致能夠看懂。處理反編譯的程式碼,一種思路是直接將程式碼全部拷貝過來然後修改,另外一種辦法是隻看核心程式碼的實現,根據反編譯程式碼提供的思路我們自己編寫程式碼。具體使用哪種辦法需要視情況而定。
五、程式碼分析
1、onSensorChanged
public void onSensorChanged(SensorEvent sensorEvent) {
int type = sensorEvent.sensor.getType();
if (type == TYPE_ORIENTATION) { // 如果是方向變化了
CompassActivity.this.mTargetDirection = CompassActivity.this.normalizeDegree(sensorEvent.values[0]); // 獲得目標方向
if (CompassActivity.this.mCompassView != null && !CompassActivity.this.mStopDrawing) {
float targetDirection = CompassActivity.this.mTargetDirection;
// 去除無用的轉動
if (targetDirection - CompassActivity.this.mCurrentDirection > 180.0f) {
targetDirection -= 360.0f;
} else if (targetDirection - CompassActivity.this.mCurrentDirection < -180.0f) {
targetDirection += 360.0f;
}
float directionInv = targetDirection - CompassActivity.this.mCurrentDirection; // 計算需要轉動的間隔
float directionPre = directionInv;
if (Math.abs(directionPre) > 0.1f) {
directionPre = directionPre > 0.0f ? 0.1f : -0.1f;
}
CompassActivity.this.mCurrentDirection = CompassActivity.this.normalizeDegree((CompassActivity.this.mInterpolator.getInterpolation(
Math.abs(directionPre) >= 0.1f ? 0.4f : 0.3f) * (directionInv)) + CompassActivity.this.mCurrentDirection); // 這裡採用加速插值器,讓轉動看起來更加流暢
if (((double) Math.abs(CompassActivity.this.mLastDirection - CompassActivity.this.mCurrentDirection)) > 0.05d) { // 如果需要轉動的角度大於0.05,則重新整理介面更新UI
CompassActivity.this.mCompassView.a(CompassActivity.this.mCurrentDirection);
CompassActivity.this.mLastDirection = CompassActivity.this.mCurrentDirection;
}
}
}
}
這裡在根據方向重新整理介面時特意加入了一個插值器,是為了增加體驗效果,不讓指南針一下就轉動到目標位置,而是先快後慢的轉動過去,讓動畫看起來更加流暢。
2、Compass的onDraw
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 繪製最外面的邊界,是一個Drawable,這裡注意利用translate和rotate函式來進行位移和旋轉
canvas.save();
canvas.translate(this.mHalfWidth - mBoundaryDrawable.getIntrinsicWidth() / 2, this.mMarginTop);
canvas.rotate(-this.mDirection, (float) mBoundaryDrawable.getIntrinsicWidth() / 2, (float) mBoundaryDrawable.getIntrinsicHeight() / 2);
mBoundaryDrawable.draw(canvas);
canvas.restore();
// 繪製中間的紅色固定不動的部分,也是一個Drawable
canvas.save();
canvas.translate(this.mHalfWidth - mReferenceDrawable.getIntrinsicWidth() / 2, this.mMarginTop + (mBoundaryDrawable.getIntrinsicHeight() - mReferenceDrawable.getIntrinsicHeight()) / 2);
mReferenceDrawable.draw(canvas);
canvas.restore();
// 繪製東南西北
canvas.save();
float descent = (((this.j.descent() - this.j.ascent()) / 2.0f) * 2.0f) - this.j.descent();
int i = 0;
canvas.rotate((-this.mDirection), (float) this.mHalfWidth, (float) this.w);
while (i < 4) {
this.j.setColor(i == 0 ? 0xFFF15238 : -1);
float measureText = this.j.measureText(mDirectionStringArray[i]);
if (i != 0) {
canvas.rotate(90, (float) this.mHalfWidth, (float) this.w); // 每次繪製一個字完後位移90度
}
canvas.drawText(mDirectionStringArray[i], ((float) this.mHalfWidth) - (measureText / 2.0f), (b(39.0f) + this.mMarginTop) + descent, this.j);
i++;
}
canvas.restore();
// 繪製中間方位數和文字描述
canvas.save();
canvas.drawText(this.mDirectionString, ((float) this.mHalfWidth) - (this.h.measureText(this.mDirectionString) / 2.0f), (((((this.h.descent() - this.h.ascent()) / 2.0f) * 2.0f) - this.h.descent()) + b(130.0f)) + this.mMarginTop, this.h);
canvas.drawText(this.mDirectionDetialString, ((float) this.mHalfWidth) - (this.i.measureText(this.mDirectionDetialString) / 2.0f), ((((this.i.descent() - this.i.ascent()) / 2.0f) * 2.0f) - this.i.descent()) + (this.mMarginTop + b(162.0f)), this.i);
}
程式碼都有註釋,就不詳細說明了。
6、總結
這篇文章的重點不是自定義View,而主要是提供一種思路,我們在看到其他應用有好的功能點時,可以通過反編譯apk來檢視其他應用的程式碼,如果混淆不是很嚴重,甚至可以直接使用,就算不能直接使用,也可以通過檢視別人的程式碼,給我們提供一些實現思路。記住,在檢視別人的程式碼之前,應該首先大致分析一下其實現,這樣能讓自己的印象更加深刻。
最後附上魅族工具箱的apk