一、说明
偶尔点开魅族手机内置的工具箱应用,发现其指南针做的还不错,就想模拟做一个类似的效果,在这里我们不准备自己从头开始编写代码,而是采用一点黑科技,首先,我们从魅族系统中导出工具箱应用的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