因一纸设计稿,我把竞品APP扒得裤衩不剩(上)

21,444 阅读18分钟

0x0、久违的碎碎念


  • 惭愧 => 离上一篇文章的发布已过三个月,倒不全是因为偷懒,而是琐事缠身
  • 本来 => 想着花个两个月刷刷题,趁着金九银十的空当另谋高就;
  • 结果 => 时间都搭在公司新做的APP上,辣鸡产品和后台混合双打,头皮发麻;
  • 导致 => 小弟我N次挑灯夜战加班到深夜,多次怀疑人生;
  • 尽管 => 疲于应付ZZ项目和人才,没学到什么新东西;
  • 但是 => 还是想写点什么,不然就真变成废人了;
  • 看到 => 上一篇《忘了他吧!我偷别人APP的代码养你》反响不错;
  • 觉得 => 大家对于偷代码一事,饶有兴致,又吐槽「掘金的消息卡片代码」小儿科;
  • 决定 => 继续偷代码,「先偷UI效果」,然后讲解「逆向相关的基础技术」;
  • 偷谁 => 竞品APP「XX英语」;
  • 为啥 => 当然不会是空穴来风,且听我娓娓道来~

郑重声明
 

  • 1、笔者只是出于对技术的好奇,无恶意破坏APP;
  • 2、仅用于技术学习,尊重原开发者的劳动成果,未用于商业用途;

0x1、直接把你要抄的竞品拿过来


记得开完「所谓的需求评审」后的第三天,设计师丢来了一纸设计稿,有个这样的页面:

然后过来和我叽里呱啦地说了一堆:

这个页面显示所有课程,然后可以滑动,滑动的同时背景也要跟着动…

听得我是:???

那句 短小但精悍 的口头禅脱口而出:

直接把你借(chao)鉴(xi)的竞品APP拿来~

接着设计师打开竞品APP「XX英语」并给我展示了一番:

Yo~ 游戏通关类的学习APP耶,记得好久以前在一款英语APP上也看到这种页面,不过人家用Cocos2d做的,如果这个也是这样,就没法做了,先来辨别「页面是不是原生写的」。


0x2、如何辨别页面是不是原生写的


辨别方法很简单,手机依次:

打开「开发者工具」 -> 勾选「显示布局边界

如果出现如下所示的边框和线:

则说明就是原生写的,否则就可能是Cocos2d,网页或者自定义控件等了。既然原生,说明有戏,不过可能要花些时间,习惯性地「装出一副很为难的样子

套用「应该、也许、可能」等不确定的辞藻劝退设计师后,开始把玩起了这款竞品APP,第一感觉「精美」,屌打我方APP,「设计,动效,原画,内容」全方位碾压,不知道我方产品弟弟哪来的自信想着捞钱:

本来只是想看下这个页面是怎么实现的,结果却「一发不可收拾」:

那种感觉就好像:

  • 有个认识了很久的 老司机,说要带你去一趟 大保健,涨涨见识;
  • 你呢:早就听说过大保健了,但没人带路不敢去,有点忐忑,怂,还嫌有点
  • 碍于面子,你还是接受了邀请,点了个最便宜的 洗脚,心想就洗个脚,洗完就走;
  • 经理 老练地把你带到一个 有些阴暗 的房间,让你等候,技师马上就来;
  • 经理走后,你 好奇得像个孩子,翻遍了整个房间,却没找到洗脚用的盘子;
  • 短促的敲门声想起,“先生,可以进来吗?”,甜美的声音 吓得你赶忙坐回原位;
  • 进来吧”,一位 身材姣好的女子 推门而入,“靓仔,久等了,不好意思,今晚人太多了”;
  • 昏暗的灯光技师脸上浓浓的妆,让你有些看不清她的模样,直觉告诉你她可能芳龄25-35之间;
  • 你也不好一直盯着技师的脸,毕竟这样不礼貌,一时间不知说啥,气氛略显尴尬;
  • 你憋出了一句:“那个,我不是点了洗脚嘛,怎么没见到洗脚盘?”
  • 技师 微微一笑:“噢,洗脚的技师都上钟了,估计要等2个小时”,并再次强调今晚人多
  • 你有些 不满:“那怎么办,我钱都给了,技师不够,经理也没和我说啊!”;
  • 技师 略带歉意:“靓仔,真的不好意思,要不给你换成 推背?”
  • 你:“推背?价钱一样吗?干嘛的?”
  • 技师:“就是推推背,按摩按摩穴位,促进血液循环,就加100块钱。”
  • 你:“哇,贵这么多,我洗个脚才45,算了算了,不按了”,然后准备穿鞋子走人;
  • 技师挽住你的手臂:“靓仔,你朋友点了这个,你出去等也要等45分钟,难得来一次,试试嘛!”;
  • 你转念一想:也对,出去等无聊不说,老司机出来看到我坐着,多没面子啊
  • 贵100就100吧,反正就来一次(然而这东西和女装一样,只有零次和无限次
  • 行吧,加100推拿”,技师一听,不禁 笑靥如花,你竟看得有些走神;
  • 有些腼腆地和技师聊着天,过了一会儿,经理敲门,送进来了一个小篮子
  • 你瞄了瞄篮子里装的东西:几个小罐像蚊帐一样通透的布,以及 两颗果冻
  • 布我可以理解,可能是拿来擦拭的,这两个果冻是?零食么?但是未免太抠门了吧?
  • 技师一声:“靓仔,牛奶还是精油开背”,把你的发散思绪拉了回来;
  • “牛奶吧”,按技师吩咐,褪去上衣,一趴,接着开始推背,手势真的不错,
  • 按得你是一阵酥软,加之技师的对你的一顿吹捧,不禁有些飘飘然;;
  • 45分钟眨眼就到,门口的上钟铃响起宣告了此次推拿的结束;
  • 你有些 意犹未尽,技师仿佛看穿了你的心思,“靓仔,舒服吧,要不要 加钟?”
  • 你:“嗯,挺舒服的,加钟的话多少钱,还是推背嘛?”
  • 技师忽而 脸泛微红,“也是100,还是推,就是推的方式和部位有点不一样…”
  • 你似乎get√到了什么,“Yo?有点意思,行,加100,我倒要看你怎么推。”
  • 技师:“嗯”,说完拿出小篮子里的 那块布两颗果冻
  • 此刻你终于知道了:
  • 不是一块布,而是一件 非常通透的衣服
  • 两颗果冻 也不是零食,而是「水晶之恋」的道具;
  • 一顿翻云覆雨的马赛克,To be continue…

以上故事纯属虚构!!!笔者也是从别人那里听回来的,没去过这种地方!!!

只是想表达「扒代码」是一件很有趣的事,从想扒「一个UI效果」到扒「所有UI效果」,再到扒「数据」和「架构」,扒得一点不剩,最后再「为我所用」的过程。像极了从一开始只是想「洗脚」到后面的「水晶之恋」「环游」「冰火两重天」等的你。不过还是建议多看看「优秀的开源项目」,毕竟「路边野花」(偷代码),吸引你的不是,而是笔者没啥文化,只能找到这种粗俗的例子来表达自己的感受,还望读者 海涵 ~

行吧,废话说得有点多了,继续本节内容!

对了,事后从老司机那里得知:这里 并没有洗脚的技师


0x3、我倒要看看你这X里卖的什么药


从开发者助手得知了一些有用信息:

  • 1、应用包名:com.knowbox.en
  • 2、当前页面名称为:MainActivity
  • 3、当前Fragment为:MapFragment

接着键入下述adb命令,获取当前栈顶Activity相关的信息:

adb shell dumpsys activity top > info.txt

打开info.txt输出文件,定位到MainActivity,看下布局层次结构:

BaseUIRootLayoutMapViewPager五个RecyclerView映入眼帘,em…实现原理该不会是:「滑动偏移错位

即:当一个列表滑动时,其他列表跟着滑动不同的距离,比如列表滑动10,其他列表分别滑动102,103, 10*4

猜想有了,接下来反编译验证一波,没加固,直接执行反编译批处理脚本(自己写的):

静待反编译完成:

接着,Android Studio导入反编译后的jadx目录(apktool目录是smail代码的):

接着全局搜索文件:MapFragment,然后文件内搜:R.layout.,找到布局文件名:

接着全局搜布局文件:layout_main_map

em…布局和我们adb dumpsys的内容一样,五个RecyclerView,接着打开MapFragment开始跟代码,
然而开头OnScrollListener的就给出了答案:

这里的bcde是混淆变量名,往下翻可以看到:

2131690465是控件ID,全局搜下,在R文件中可以找到对应值

找到对应的id,这里直接替换:

见名知意,前中后三个背景图一个线,剩下一个应该就是设置了这个滚动监听的列表了,定位下:

行吧,就是滑动偏移错位,噢,突然想到一个问题,几个列表都能滑动耶,怎么以这个列表为准:


onTouch()返回true,使得Recyclerview的onTouchEvent方法不被调用(从而屏蔽用户滑动与点击)。

行吧,大概了解了,开始搬运~


0x4、偷:①滑动偏移错位的效果


1、列表内容布局

无脑搬运布局,只是外层用的ConstraintLayout布局包裹:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent">


    <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rv_back_level_bg"
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"/>


    <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rv_middle_level_bg"
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"/>


    <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rv_front_level_bg"
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"/>


    <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rv_line"
            android:layout_width="0dp"
            android:layout_height="80dp"
            android:paddingStart="135dp"
            android:paddingEnd="80dp"
            app:layout_constraintStart_toStartOf="@id/rv_main_homework"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            android:clipToPadding="false"/>


    <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rv_main_homework"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:paddingStart="55dp"
            android:paddingEnd="0dp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            android:clipToPadding="false"/>


</androidx.constraintlayout.widget.ConstraintLayout>

Tips

这里有两个RecyclerView用到了 android:clipToPadding="false",作用是让布局能绘制到padding区域,不是很明白,看下分别设置true和false的效果就知道了:
 

   

 
另外,需要和另外一个属性:clipChildren 进行区分,这个属性是:设置子view是否可以超出父view!!!


2、前中后背景图片Adapter


三个背景图用一个Adapter,在 res和assets目录 中并没有找到对应的图片文件,估摸着素材是联网下载的,猛地想起,一开始进入APP的时候有过下载资源。清理下数据,打开Fiddler抓下包,打开APP:

20多M耶,也没加什么校验,浏览器直接打开,把文件下载到本地解压:

em…看下文件名,不难发现有三类图片,前中后,依次打开图片:

图片高度都是750,除了最后一张宽度是不确定的,其他都是500,这里就不去下载解压了,直接把图片都丢drawable-xxhdpi文件夹中,但是有一点要注意「图片名不能数字开头!!!」,不然等下索引会报错,开头全部加上bg_前缀吧,懒得一个个手动改了,随手写个批量重命名脚本吧:

import os

pic_source_dir = os.path.join(os.getcwd(), "lisk5"+os.sep)  # 原图路径

if __name__ == '__main__':
    file_list = []
    f = os.listdir(pic_source_dir)
    for i in f:
        if i.endswith(".png"):
            os.rename(os.path.join(pic_source_dir, i),
                      os.path.join(pic_source_dir, "bg_%s" % i))
    print("批处理完成!")

在写Adapter前,先来写每个Item的布局吧,无脑 布局套ImageView,高度占满,宽度自适应,示例如下:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto">


    <androidx.appcompat.widget.AppCompatImageView
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        android:adjustViewBounds="true"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        android:src="@drawable/bg_1_back_01" />


</androidx.constraintlayout.widget.ConstraintLayout>

不过,学过性能优化的都知道:「应尽量减少不必要的布局层次嵌套」,我们这样玩的话,要叠三层ConstraintLayout… 其实吧,动态添加一个ImageView就好了,只是要 确定(计算) 好它具体的宽高。啧啧,看下APP是如何实现的,搜文件 MainHomeworkBgAdapter,定位到 setLayoutParams

哇,这里好多a啊,一个个来看,先是 ViewHolder的a

噢,这是定义了一个ImageView,接着到 onBindViewHolder 处的两个 a(((xxx) this.c.xxx

噢,执行a函数,作用:利用Bitmap获取图片宽度返回,同时ImageView设置图片
接着到this.a,即最外层的a,存储宽度的临时变量,这一段代码有点意思:

我们从解压的资源包知道,除了最后一项外,其他图片宽度皆为500,而服务器内部错误码也是500~

这个临时变量在构造方法中完成了初始化:

定位到UIUtilsb函数:

em,就是获取屏幕的高度,到此整个流程就一清二楚了,动手写出Adapter


3、斜虚线Adapter


接着到虚线列表,打开res和assets没发现虚线图片,应该就是自定义View了,回到 MapFragment.kt,定位到设置adapter的位置,可以看到这个LineAdapter

LineAdapter,可以看到ViewHolder里有一个LineView,跟进去:

LineView,代码如下:

简单说下流程

  • 1、构造方法setWillNotDraw(false),没记错的话,重写ViewGroup才需要用到,设置false让ViewGroup可以onDraw(),里面调用了一个方法a;
  • 2、方法a初始化Paint画笔Path路径
  • 3、onLayout方法:获取宽高;
  • 4、onDraw方法:根据向上还是向下设置起始和终点Y坐标,接着绘制直线
  • 5、setIsUp方法:设置绘制的方向是向上还是向下。

同样搬运一波代码

接着回到LinearAdapter,比较简单,核心的就这里:

先是SetVisibility这里,0和4分别是「VISIBLE」和「INVISIBLE」,接着是圈住的判断条件:头尾虚线不显示可以理解,就是这个this.a 是干嘛的?直接搬运代码,看下不判断会怎样:

运行后

卧槽,少了一个,所以这个this.a到底是干嘛的?可以看到构造方法中传入了一个z,跟:

z的初始值为false,判断了一波this.j.i是否等于1,是的话等于true,那么this.j.i到底是啥?这里就不跟了,直接用「smail动态调试」这个APP,「前戏如何准备,下一节教你」,这里假设前戏已做好,开始调教~ 找到大概的位置下断点:

终端命令执行脚本:

手机显示Waiting for Debugger,等待 插入…呸,调试,选择APP进行,点击OK

来到断点位置,程序会自动挂起,AS弹出Android Debugger窗口。

可以看到传入Adapter的参数50和true,然后是这个this.j.i,但是确是一个字符串:“1-49”,卧槽,判断字符串是否等于整数??? 什么鬼?

if(字符串 == 1)

编译都不通过吧,大哥,直接看 this.j

定位到OnlineMainCourseIndexInfo类:

从parse那里可以看出这个i应该是当前地图的ID,但是却变成了“1-49”,这个更像j当前地图等级吧,而g更像是openCartoonVideo,这里应该懂了吧,不是一一对应的!所以其实对应的参数是h,即1,代表第几关,那直接忽略吧,修改后的代码如下:

运行后:

可以,就是我们想要的效果,剩下前面的Adapter了。


4、前面的Adapter


直接定位到MainHomeworkAdapter

啧啧,RecyclerView多Item布局,见名知意,表头表尾,以及中间,搬运写出Adapter雏形(这里就不写点击事件了)

数据类有两个变量暂且不知道是干嘛的:

无脑搬运三个布局,接着开始写Adapter,先是CommonAdapter,部分代码如下:

而StartHolder和EndHolder则比较简单:

Adapter写好了,接着就是造数据了,依旧下断点调试,

复制粘贴,循环造点假数据:

修修补补后,运行下看下效果:

行吧,算是偷取完成了~


0x5、偷:②字体TextView


我们都知道可以调用TextView的setTypeface设置字体,如果一个APP用到了多个字体包,每次都去设置显得有些繁琐,这个APP直接重写TextView,直接XML引用,方便多了,笔者在原先基础上做点小改动,有默认字体,可在XML中单独设置字体,attrs.xml中添加属性一枚:

接着EnTextView继承TextView,获取属性,设置字体:

接着XML中设置下属性即可:


0x6、偷:③ Airbnb的Lottie

其实竞品中大部分看起来很精美的动画都是用到了AribnbLottie库,比如下面这个动画(漂浮的大象,还会眨眼):

还有白圈扩散波纹的动画,如果让你来做,你会怎么做?

  • 1、帧动画:需要添加大量图片(尺寸适配),势必会导致APK体积暴涨;
  • 2、Gif:Gif图占用空间较大,且需适配多种屏幕,影响同上;
  • 3、属性动画 + 图片 + SVG:繁琐且不易维护,稍作修改可能就要推倒重来;

用Lottie库可以让我们开发仔免于纠结复杂的动画效果,网上关于它的介绍有很多,这里就不再做复读机了,直接说怎么玩,需要:

Step 1:设计师通过AE(After Effects)和 Bodymovin插件 将动画导出JSON文件;
Step 2:开发仔把JSON文件丢到app/src/main/assets目录下
Step 3:build.gradle导入lottie-android库,XML中引入LottieAnimationView直接使用。

更多使用说明可见:

搬运:

接着补全下右侧显示动画,点击后滚动会起始位置

运行效果如下:


0x7、小结


虽然前面立FLAG说要「扒所有的UI效果」,但却只演示一个,毕竟写文章的目的只是展示技法,让读者举一反三,而且扒别人源码也不是件简单的事情。一堆混淆的abcd看到眼花,然后各种继承父类嵌套,耦合,一堆没用到的代码,要把一个单独的控件抽取出来,非常耗费时间。还是那句话,设计或产品让抄的时候再去扒,会实际一些,带着目的去看源码!UI相关的就先到这里,下节讲解一波,笔者扒别人APP用到的所有「基础逆向操作」谢谢~

对了,混掘金也挺久了,继白嫖笔记本后,前些天又白嫖了一个鼠标垫感恩
意思意思送「一本自己写的Python爬虫入门书」吧,评论区留言抽,包邮,下周五抽~


源码地址github.com/coder-pig/S…


参考文献:


恭喜「ALuoBo」童鞋中奖~抽奖录屏在:www.bilibili.com/video/av757…