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,看下布局层次结构:
BaseUIRootLayout,MapViewPager,五个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~
这个临时变量在构造方法中完成了初始化:
定位到UIUtils 的 b函数:
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
其实竞品中大部分看起来很精美的动画都是用到了Aribnb的Lottie库,比如下面这个动画(漂浮的大象,还会眨眼):
还有白圈扩散波纹的动画,如果让你来做,你会怎么做?
- 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爬虫入门书」吧,评论区留言抽,包邮,下周五抽~
参考文献:
恭喜「ALuoBo」童鞋中奖~抽奖录屏在:www.bilibili.com/video/av757…