反编译实战之仿写小红书图片指示器

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. 初始化方法,设置图片的张数;
  2. 图片滑动时更改指示器的方法,传入当前在哪张图片上;

刚好在反编译结果中,这两个方法的方法名没有被混淆,所以能够很快确定这两个方法:

// 方法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件事:

  1. 根据图片数,控制View的显示与否;
  2. 初始化变量;
  3. 控制只初始化一次;
  4. 设置控件的宽度;
  5. 往ViewGroup中添加圆点ImageView;
  6. 设置第一个点的选中态;
  7. 设置最后一个点的大小;

从上述方法中,同样能确定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,可以推断出分别是用来做位移动画、非选中圆点缩小动画、选中圆点放大动画的,分别从重命名为playAnimationstartDotAnimationForUnSelectedstartDotAnimationForSelected

至此,还有最后一个方法没有完全解析,即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的原理:

  1. 有几张图片就有多少个圆点;
  2. View的可视区域仅有最多5个点的宽度范围,在切换过程中做平移和圆点放大缩小的动画;

将可视区域放开,原理就很显而易见了,可见下方gif。

[图片上传失败...(image-6e46cb-1677396619762)]

代码已开源至github,欢迎大家不吝赐教~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,732评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,496评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,264评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,807评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,806评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,675评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,029评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,683评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,704评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,666评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,773评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,413评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,016评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,204评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,083评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,503评论 2 343

推荐阅读更多精彩内容