郑重声明:
- 1、笔者只是出于对技术的好奇,无恶意破坏APP;
- 2、仅用于技术学习,尊重原开发者的劳动成果,未用于商业用途;
记得开完「所谓的需求评审」后的第三天,设计师丢来了一纸设计稿,有个这样的页面:
然后过来和我叽里呱啦地说了一堆:
这个页面显示所有课程,然后可以滑动,滑动的同时背景也要跟着动…
听得我是:???
那句 短小但精悍 的口头禅脱口而出:
直接把你借(chao)鉴(xi)的竞品APP拿来~
接着设计师打开竞品APP「XX英语」并给我展示了一番:
Yo~ 游戏通关类的学习APP耶,记得好久以前在一款英语APP上也看到这种页面,不过人家用Cocos2d做的,如果这个也是这样,就没法做了,先来辨别「页面是不是原生写的」。
辨别方法很简单,手机依次:
打开「开发者工具」 -> 勾选「显示布局边界」
如果出现如下所示的边框和线:
则说明就是原生写的,否则就可能是Cocos2d,网页或者自定义控件等了。既然原生,说明有戏,不过可能要花些时间,习惯性地「装出一副很为难的样子」
套用「应该、也许、可能」等不确定的辞藻劝退设计师后,开始把玩起了这款竞品APP,第一感觉「精美」,屌打我方APP,「设计,动效,原画,内容」全方位碾压,不知道我方产品弟弟哪来的自信想着捞钱:
本来只是想看下这个页面是怎么实现的,结果却「一发不可收拾」:
那种感觉就好像:
以上故事纯属虚构!!!笔者也是从别人那里听回来的,没去过这种地方!!!
只是想表达「扒代码」是一件很有趣的事,从想扒「一个UI效果」到扒「所有UI效果」,再到扒「数据」和「架构」,扒得一点不剩,最后再「为我所用」的过程。像极了从一开始只是想「洗脚」到后面的「水晶之恋」「环游」「冰火两重天」等的你。不过还是建议多看看「优秀的开源项目」,毕竟**「路边野花」(偷代码),吸引你的不是香**,而是野。笔者没啥文化,只能找到这种粗俗的例子来表达自己的感受,还望读者 海涵 ~
行吧,废话说得有点多了,继续本节内容!
对了,事后从老司机那里得知:这里 并没有洗脚的技师…
从开发者助手得知了一些有用信息:
- 1、应用包名:com.knowbox.en
接着键入下述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方法不被调用(从而屏蔽用户滑动与点击)。
行吧,大概了解了,开始搬运~
无脑搬运布局,只是外层用的ConstraintLayout布局包裹:
Tips
这里有两个RecyclerView用到了 android:clipToPadding=“false”,作用是让布局能绘制到padding区域,不是很明白,看下分别设置true和false的效果就知道了:
另外,需要和另外一个属性:clipChildren 进行区分,这个属性是:设置子view是否可以超出父view!!!
三个背景图用一个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,高度占满,宽度自适应,示例如下:
不过,学过性能优化的都知道:「应尽量减少不必要的布局层次嵌套」,我们这样玩的话,要叠三层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
接着到虚线列表,打开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了。
直接定位到MainHomeworkAdapter:
啧啧,RecyclerView多Item布局,见名知意,表头表尾,以及中间,搬运写出Adapter雏形(这里就不写点击事件了)
数据类有两个变量暂且不知道是干嘛的:
无脑搬运三个布局,接着开始写Adapter,先是CommonAdapter,部分代码如下:
而StartHolder和EndHolder则比较简单:
Adapter写好了,接着就是造数据了,依旧下断点调试,
复制粘贴,循环造点假数据:
修修补补后,运行下看下效果:
行吧,算是偷取完成了~
我们都知道可以调用TextView的setTypeface设置字体,如果一个APP用到了多个字体包,每次都去设置显得有些繁琐,这个APP直接重写TextView,直接XML引用,方便多了,笔者在原先基础上做点小改动,有默认字体,可在XML中单独设置字体,attrs.xml中添加属性一枚:
接着EnTextView继承TextView,获取属性,设置字体:
接着XML中设置下属性即可:
其实竞品中大部分看起来很精美的动画都是用到了Aribnb的Lottie库,比如下面这个动画(漂浮的大象,还会眨眼):
还有白圈扩散波纹的动画,如果让你来做,你会怎么做?
用Lottie库可以让我们开发仔免于纠结复杂的动画效果,网上关于它的介绍有很多,这里就不再做复读机了,直接说怎么玩,需要:
Step 1:设计师通过AE(After Effects)和 Bodymovin插件 将动画导出JSON文件;
Step 2:开发仔把JSON文件丢到app/src/main/assets目录下
Step 3:build.gradle导入lottie-android库,XML中引入LottieAnimationView直接使用。
更多使用说明可见:
搬运:
接着补全下右侧显示动画,点击后滚动会起始位置
运行效果如下:
虽然前面立FLAG说要「扒所有的UI效果」,但却只演示一个,毕竟写文章的目的只是展示技法,让读者举一反三,而且扒别人源码也不是件简单的事情。一堆混淆的abcd看到眼花,然后各种继承父类嵌套,耦合,一堆没用到的代码,要把一个单独的控件抽取出来,非常耗费时间。还是那句话,设计或产品让抄的时候再去扒,会实际一些,带着目的去看源码!UI相关的就先到这里,下节讲解一波,笔者扒别人APP用到的所有「基础逆向操作」谢谢~
源码地址:https://github.com/coder-pig/StealInterestingCode
参考文献:
- android:Lottie–让Android动画更优雅
- Lottie - 让复杂动画如此简单