1. 前言
最近在一个ui改版项目中,pm和ui,给提了一个需求,就是把商品头图的图片指示器进行改版,从数字指示器,换成了小圆点。从prd描述和ui图中一看,好家伙,这不就是小红书吗?
想着应该也挺简单,然后照着小红书的效果,自己写了一下,在onDraw中实时画圆形,当滑动图片时做动画,且更换选中圆的状态。
但是写下来之后,图片一多就有bug了,修了一天下来没有进展,遂萌生了反编译小红书apk的想法。
最终实现效果
[图片上传失败...(image-c3e6b8-1677396619762)]
2. 反编译
这里推荐一个很好用的反编译工具,jadx,Windows/MacOS/Linux 等主流系统均可使用。这里我用的mac,安装命令:
brew install jadx
非常简单,脚本运行完之后,终端输入jadx-gui即可打开工具的gui界面,将需要反编译的apk或jar包拖入,稍等一会即可看到反编译的结果,非常方便,省去了将apk中的dex包反编译成smail再反编译成class的麻烦。
[图片上传失败...(image-389bb1-1677396619762)]
可以看到已经反编译完了,只需要知道想反编译的类,即可看到源码。
那问题来了,怎么知道小红书的图片指示器的View名字是什么呢?🤔
这里推荐我司开源的工具CodeLocator,可以准确抓出View的属性,如果app中接入了SDK,还可以定位到xml、点击事件、ViewHolder、Fragment、Activity等代码的位置,可以说是一个升级版的Layout Inspector。
通过工具,抓到指示器View的名字是DotIndicatorV2View。
[图片上传失败...(image-d23830-1677396619762)]
在jadx中搜索,果然搜到了,并且只有唯一的一个。
[图片上传失败...(image-899e76-1677396619762)]
[图片上传失败...(image-6c73ba-1677396619762)]
反编译结果还算可以,只是代码被混淆了,手上也没有小红书的mapping文件,需要人工对混淆过的代码进行解读。
3. 人工代码解混淆
3.1 确定向外暴露的方法
通过该View的表现形式,需要对外暴露两个方法:
- 初始化方法,设置图片的张数;
- 图片滑动时更改指示器的方法,传入当前在哪张图片上;
刚好在反编译结果中,这两个方法的方法名没有被混淆,所以能够很快确定这两个方法:
// 方法1,初始化方法
public final void setCount(int i) {
int i2;
if (i <= 1) {
// 猜测是将View隐藏
ViewExtensions.m238052b(this);
return;
}
// 猜测是将View展现
ViewExtensions.m238038p(this);
if (i == this.f67649f) {
m173332c(0);
return;
}
removeAllViews();
this.f67650g.clear();
this.f67647d = 0;
this.f67646c = 0;
this.f67649f = i;
int i3 = this.f67648e;
if (i >= i3) {
i2 = (this.f67644a * i3) + ((i3 - 1) * this.f67645b);
} else {
i2 = ((i - 1) * this.f67645b) + (this.f67644a * i);
}
getLayoutParams().width = i2;
ViewGroup.LayoutParams layoutParams = getLayoutParams();
Objects.requireNonNull(layoutParams, "null cannot be cast to non-null type android.widget.LinearLayout.LayoutParams");
((LinearLayout.LayoutParams) layoutParams).gravity = 1;
for (int i4 = 0; i4 < i; i4++) {
ImageView m173333b = m173333b(i4);
addView(m173333b);
this.f67650g.add(m173333b);
}
Drawable drawable = this.f67650g.get(0).getDrawable();
Objects.requireNonNull(drawable, "null cannot be cast to non-null type android.graphics.drawable.TransitionDrawable");
((TransitionDrawable) drawable).startTransition(0);
int i5 = this.f67648e;
if (i <= i5) {
return;
}
this.f67650g.get(i5 - 1).setScaleX(0.6f);
this.f67650g.get(this.f67648e - 1).setScaleY(0.6f);
}
// 方法2,设置当前所在的位置
public final void setSelectedIndex(int i) {
int i2 = this.f67646c;
if (i != i2) {
boolean z = false;
if (i >= 0 && i < this.f67649f) {
z = true;
}
if (!z) {
return;
}
if (Math.abs(i - i2) > 1) {
m173332c(i);
} else if (this.f67649f <= this.f67648e) {
Drawable drawable = this.f67650g.get(this.f67647d).getDrawable();
Objects.requireNonNull(drawable, "null cannot be cast to non-null type android.graphics.drawable.TransitionDrawable");
((TransitionDrawable) drawable).reverseTransition(200);
Drawable drawable2 = this.f67650g.get(i).getDrawable();
Objects.requireNonNull(drawable2, "null cannot be cast to non-null type android.graphics.drawable.TransitionDrawable");
((TransitionDrawable) drawable2).startTransition(200);
int i3 = this.f67646c;
if (i > i3) {
this.f67646c = i3 + 1;
this.f67647d++;
return;
}
this.f67646c = i3 - 1;
this.f67647d--;
} else if (i > this.f67646c) {
m173330e();
} else {
m173331d();
}
}
}
3.2 明确几个成员变量
反编译出来的代码中,有9个成员变量:
/* renamed from: a */
public int f67644a;
/* renamed from: b */
public int f67645b;
/* renamed from: c */
public int f67646c;
/* renamed from: d */
public int f67647d;
/* renamed from: e */
public final int f67648e;
/* renamed from: f */
public int f67649f;
/* renamed from: g */
public ArrayList<ImageView> f67650g;
/* renamed from: h */
public int f67651h;
/* renamed from: i */
public Map<Integer, View> f67652i;
通过其构造方法:
public DotIndicatorV2View(Context context, AttributeSet attributeSet, int i) {
super(context, attributeSet, i);
Intrinsics.checkNotNullParameter(context, "context");
this.f67652i = new LinkedHashMap();
Resources system = Resources.getSystem();
Intrinsics.checkExpressionValueIsNotNull(system, "Resources.getSystem()");
// 翻译下来就是5dp,应该是圆点的大小
this.f67644a = (int) TypedValue.applyDimension(1, 5, system.getDisplayMetrics());
Resources system2 = Resources.getSystem();
Intrinsics.checkExpressionValueIsNotNull(system2, "Resources.getSystem()");
// 翻译下来是3dp,应该是小圆点的大小或边距
this.f67645b = (int) TypedValue.applyDimension(1, 3, system2.getDisplayMetrics());
// 最多5个圆点
this.f67648e = 5;
this.f67650g = new ArrayList<>();
this.f67651h = R$drawable.red_view_indicator_transition_v2;
}
可以推断出:
- f67644a:圆点大小,命名为normalSize
- f67644b:小圆点大小或margin值,命名为smallSize
- f67648e:最大圆点数,命名为MAX_DOT_SIZE
- f67650g:圆点ImageView的集合,命名为dotList
- f67651h:圆点View背景色的Drawable资源,命名为res
且在整个类中搜索了一下,f67652i这个变量除了初始化,并无其他地方调用,不再考虑该变量。至此还有2个变量需要推断。
3.3 setCount方法解析
将3.2中解析出来的变量重命名替换回去,并用kotlin改写做一些改造:
fun setCount(count: Int) {
// 数量小于1,则隐藏View
if (count <= 1) {
visibility = View.GONE
return
}
visibility = View.VISIBLE
// 如果数量一致,则跳转到第一个,因为无需再做重复的事情
if (count == f67646f) {
m17333c(0)
return
}
// 初始化变量
removeAllViews()
dotList.clear()
f67647d = 0;
f67646c = 0;
f67646f = count
// 设置控件的宽度,分超出最多点或最多点以内
val width = if (count >= MAX_DOT_SIZE) {
normalSize * MAX_DOT_SIZE + (MAX_DOT_SIZE - 1) * smallSize
} else {
(count - 1) * smallSize + normalSize * count
}
layoutParams.width = width
// 往ViewGroup中添加View
for (i in 0 until count) {
// 猜测m173333b方法为创建圆点ImageView的方法
val dot = m173333b(i)
addView(dot)
dotList.add(dot)
}
// 设置第一个点位选中态
val drawable = dotList[0].drawable
(drawable as? TransitionDrawable)?.startTransition(0)
// 如果图片数量小于设置的最多的圆点数,则返回,5个以内的话,所有圆点大小一致
if (count <= MAX_DOT_SIZE) return
// 将最后一个点变小
dotList.get(MAX_DOT_SIZE - 1).setScaleX(0.6f);
dotList.get(MAX_DOT_SIZE - 1).setScaleY(0.6f);
}
这个方法中一共做了7件事:
- 根据图片数,控制View的显示与否;
- 初始化变量;
- 控制只初始化一次;
- 设置控件的宽度;
- 往ViewGroup中添加圆点ImageView;
- 设置第一个点的选中态;
- 设置最后一个点的大小;
从上述方法中,同样能确定2个变量的含义:
- f67646f:图片数,亦是圆点数,重命名为imageSize;
- f67644b:圆点之间的间距;
至此还有两个变量不能推断出其含义,但是从View的表现,应该是标记当前位置的相关变量。
3.4 setSelectedIndex方法解析
将3.2和3.3中解析出来的变量重命名替换回去,并用kotlin改写做一些改造:
fun setSelectedIndex(index: Int) {
// 如果index跟f67646c相等则返回,猜测f67646c是记录上一次的值
if (index == f67646c) return
// 如果index不在 0和imageSize-1之间,则返回,避免一些数组越界的问题
if (index !in 0 until imageSize) {
return
}
if (abs(index - f67646c) > 1) {
// 非相邻图片的切换的特殊方法
m173332c(index)
} else if (imageSize <= MAX_DOT_SIZE) {
// 图片数在最大圆点数之内的情况,较为简单,仅需要做圆点选中态的切换
val drawable = dotList[m173332d].drawable
(drawable as? TransitionDrawable)?.reverseTransition(200)
val drawable2 = dotList[index].drawable
(drawable2 as? TransitionDrawable)?.startTransition(200)
if (index > realPos) {
m173332d++
m173332c++
return
}
this.m173332c = m173332c - 1
this.m173332d--
} else if (index > m173332c) {
// 向前移动
m173332e()
} else {
// 向后移动
m173332d()
}
}
从上述方法中,可以明确推断出,f67646c变量是用来记录上一张图片的索引值的,暂时命名为realPos
,至于f67646d,目前还不太清楚用来做什么。同时,可以推断出3个子方法的作用:
- m173332c:用于非相邻图片之间的切换的特殊方法;
- m173332e:向前移动的方法,重命名为stepNext;
- m173332d:向后移动的方法,重命名为stepBack;
从反编译代码中看,m173332c方法过长,我们先解析m173332e和m173332d。
3.5 向前/后移动方法解析
将变量替换进去:
private fun stepBack() {
// 将上一个点置为非选中态,当前点置为选中态
val drawable = dotList[realPos].drawable
(drawable as? TransitionDrawable)?.reverseTransition(200)
val drawable2 = dotList[realPos - 1].drawable
(drawable2 as? TransitionDrawable)?.startTransition(200)
// 第2个点时,需要做动画
if (f67646d == 1 && realPos != 1) {
m173327h(false)
if (realPos != 2) {
m173329f(realPos - 2)
}
m173329a(realPos - 1)
m173329f(realPos + 2)
} else {
f67646d--
}
realPos--
}
private fun stepNext() {
// 将上一个点置为非选中态,当前点置为选中态
val drawable = dotList[realPos].drawable
(drawable as? TransitionDrawable)?.reverseTransition(200)
val drawable2 = dotList[realPos + 1].drawable
(drawable2 as? TransitionDrawable)?.startTransition(200)
// 第4个点时,需要做动画
val i = f67646d
if (i == 3 && realPos != imageSize - 2) {
m173327h(true)
if (realPos != imageSize - 3) {
m173329f(realPos + 2)
}
m173329a(realPos + 1)
m173329f(realPos - 2)
} else {
f67646d = i + 1
}
realPos++
}
从以上代码,可以推断出变量f67646d,是用来记录真正View上所在小圆点位置的,这里重命名为curPos
。另外,进去其中三个子方法m173327h、m173329f、m173329a,可以推断出分别是用来做位移动画、非选中圆点缩小动画、选中圆点放大动画的,分别从重命名为playAnimation
、startDotAnimationForUnSelected
、startDotAnimationForSelected
。
至此,还有最后一个方法没有完全解析,即m173332c,从前面可知道是直达到某个位置的方法,这里重命名为jumpToIndex
。
3.6 jumpToIndex方法解析
将上述推断出的方法和变量替换进去,并将放大缩小圆点封装成两个方法:
private fun jumpToIndex(index: Int) {
if (index == realPos) return
if (index !in 0 until imageSize) return
var targetTransition = 0
if (imageSize <= MAX_DOT_SIZE) {
// 小于等于最多点的情况,比较简单
curPos = index
} else {
when (index) {
in imageSize - 4 until imageSize -> {
targetTransition = (imageSize - 5) * (normalSize + smallSize)
curPos = index - imageSize + 5
shrinkDot(imageSize - 5)
for (i in imageSize - 4 until imageSize) {
expandDot(i)
}
}
in 2 until imageSize - 4 -> {
val leftIndex = index - 1
targetTransition = (normalSize + smallSize) * leftIndex
this.curPos = 1
shrinkDot(leftIndex)
val rightIndex = index + 3
shrinkDot(rightIndex)
for (i in index until rightIndex) {
expandDot(i)
}
}
in 0..2 -> {
curPos = index
for (i in 0 until (MAX_DOT_SIZE - 1)) {
expandDot(i)
}
shrinkDot(MAX_DOT_SIZE - 1)
targetTransition = 0
}
}
val x = (-targetTransition) - dotList[0].x
for (i in 0 until imageSize) {
val imageView = dotList[i]
imageView.x = imageView.x + x
}
}
val drawable = dotList[realPos].drawable
(drawable as? TransitionDrawable)?.reverseTransition(0)
val drawable2 = dotList[index].drawable
(drawable2 as? TransitionDrawable)?.startTransition(0)
realPos = index
}
该方法主要是应对直接设置某个index,整个View需要怎么切换到对应的状态。
4. 原理解析
从上述代码解析,我们不难看出,该指示器View的原理:
- 有几张图片就有多少个圆点;
- View的可视区域仅有最多5个点的宽度范围,在切换过程中做平移和圆点放大缩小的动画;
将可视区域放开,原理就很显而易见了,可见下方gif。
[图片上传失败...(image-6e46cb-1677396619762)]
代码已开源至github,欢迎大家不吝赐教~