郑重声明:
html
- 一、笔者只是出于对技术的好奇,无恶意破坏APP;
- 二、仅用于技术学习,尊重原开发者的劳动成果,未用于商业用途;
记得开完「所谓的需求评审」后的第三天,设计师丢来了一纸设计稿,有个这样的页面:android
而后过来和我叽里呱啦地说了一堆:nginx
这个页面显示全部课程,而后能够滑动,滑动的同时背景也要跟着动…git
听得我是:???github
那句 短小但精悍 的口头禅脱口而出:web
直接把你借(chao)鉴(xi)的竞品APP拿来~shell
接着设计师打开竞品APP「XX英语」并给我展现了一番:浏览器
Yo~ 游戏通关类的学习APP耶,记得很久之前在一款英语APP上也看到这种页面,不过人家用Cocos2d作的,若是这个也是这样,就无法作了,先来辨别「页面是否是原生写的」。性能优化
辨别方法很简单,手机依次:bash
打开「开发者工具」 -> 勾选「显示布局边界」
若是出现以下所示的边框和线:
则说明就是原生写的,不然就多是Cocos2d,网页或者自定义控件等了。既然原生,说明有戏,不过可能要花些时间,习惯性地「装出一副很为难的样子」
套用「应该、也许、可能」等不肯定的辞藻劝退设计师后,开始把玩起了这款竞品APP,第一感受「精美」,屌打我方APP,「设计,动效,原画,内容」全方位碾压,不知道我方产品弟弟哪来的自信想着捞钱:
原本只是想看下这个页面是怎么实现的,结果却「一发不可收拾」:
那种感受就好像:
- 有个认识了好久的 老司机,说要带你去一趟 大保健,涨涨见识;
- 你呢:早就据说过大保健了,但没人带路不敢去,有点忐忑,怂,还嫌有点脏;
- 碍于面子,你仍是接受了邀请,点了个最便宜的 洗脚,心想就洗个脚,洗完就走;
- 经理 老练地把你带到一个 有些阴暗 的房间,让你等候,技师立刻就来;
- 经理走后,你 好奇得像个孩子,翻遍了整个房间,却没找到洗脚用的盘子;
- 短促的敲门声想起,“先生,能够进来吗?”,甜美的声音 吓得你赶紧坐回原位;
- “进来吧”,一位 身材姣好的女子 推门而入,“靓仔,久等了,很差意思,今晚人太多了”;
- 昏暗的灯光 和 技师脸上浓浓的妆,让你有些看不清她的模样,直觉告诉你她可能芳龄25-35之间;
- 你也很差一直盯着技师的脸,毕竟这样不礼貌,一时间不知说啥,气氛略显尴尬;
- 你憋出了一句:“那个,我不是点了洗脚嘛,怎么没见到洗脚盘?”
- 技师 微微一笑:“噢,洗脚的技师都上钟了,估计要等2个小时”,并再次强调今晚人多;
- 你有些 不满:“那怎么办,我钱都给了,技师不够,经理也没和我说啊!”;
- 技师 略带歉意:“靓仔,真的很差意思,要不给你换成 推背?”
- 你:“推背?价钱同样吗?干吗的?”
- 技师:“就是推推背,按摩按摩穴位,促进血液循环,就加100块钱。”
- 你:“哇,贵这么多,我洗个脚才45,算了算了,不按了”,而后准备穿鞋子走人;
- 技师挽住你的手臂:“靓仔,你朋友点了这个,你出去等也要等45分钟,可贵来一次,试试嘛!”;
- 你转念一想:也对,出去等无聊不说,老司机出来看到我坐着,多没面子啊。
- 贵100就100吧,反正就来一次(然而这东西和女装同样,只有零次和无限次)
- “行吧,加100推拿”,技师一听,不由 笑靥如花,你竟看得有些走神;
- 有些腼腆地和技师聊着天,过了一下子,经理敲门,送进来了一个小篮子;
- 你瞄了瞄篮子里装的东西:几个小罐,像蚊账同样通透的布,以及 两颗果冻;
- 布我能够理解,多是拿来擦拭的,这两个果冻是?零食么?可是未免太抠门了吧?
- 技师一声:“靓仔,牛奶仍是精油开背”,把你的发散思绪拉了回来;
- “牛奶吧”,按技师吩咐,褪去上衣,一趴,接着开始推背,手势真的不错,
- 按得你是一阵酥软,加之技师的对你的一顿吹捧,不由有些飘飘然;;
- 45分钟眨眼就到,门口的上钟铃响起宣告了这次推拿的结束;
- 你有些 意犹未尽,技师仿佛看穿了你的心思,“靓仔,舒服吧,要不要 加钟?”
- 你:“嗯,挺舒服的,加钟的话多少钱,仍是推背嘛?”
- 技师忽而 脸泛微红,“也是100,仍是推,就是推的方式和部位有点不同…”
- 你彷佛get√到了什么,“Yo?有点意思,行,加100,我倒要看你怎么推。”
- 技师:“嗯”,说完拿出小篮子里的 那块布 和 两颗果冻;
- 此刻你终于知道了:
- 那 不是一块布,而是一件 很是通透的衣服;
- 而 两颗果冻 也不是零食,而是「水晶之恋」的道具;
- …一顿翻云覆雨的马赛克,To be continue…
以上故事纯属虚构!!!笔者也是从别人那里听回来的,没去过这种地方!!!
只是想表达「扒代码」是一件颇有趣的事,从想扒「一个UI效果」到扒「全部UI效果」,再到扒「数据」和「架构」,扒得一点不剩,最后再「为我所用」的过程。像极了从一开始只是想「洗脚」到后面的「水晶之恋」「环游」「冰火两重天」等的你。不过仍是建议多看看「优秀的开源项目」,毕竟「路边野花」(偷代码),吸引你的不是香,而是野。笔者没啥文化,只能找到这种粗俗的例子来表达本身的感觉,还望读者 海涵 ~
行吧,废话说得有点多了,继续本节内容!
对了,过后从老司机那里得知:这里 并无洗脚的技师…
从开发者助手得知了一些有用信息:
- 一、应用包名:com.knowbox.en
- 二、当前页面名称为:MainActivity
- 三、当前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方法不被调用(从而屏蔽用户滑动与点击)。
行吧,大概了解了,开始搬运~
无脑搬运布局,只是外层用的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!!!
三个背景图用一个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
接着到虚线列表,打开res和assets没发现虚线图片,应该就是自定义View了,回到 MapFragment.kt,定位到设置adapter的位置,能够看到这个LineAdapter:
进 LineAdapter,能够看到ViewHolder里有一个LineView,跟进去:
进 LineView,代码以下:
简单说下流程:
- 一、构造方法:setWillNotDraw(false),没记错的话,重写ViewGroup才须要用到,设置false让ViewGroup能够onDraw(),里面调用了一个方法a;
- 二、方法a:初始化Paint画笔和Path路径;
- 三、onLayout方法:获取宽高;
- 四、onDraw方法:根据向上仍是向下设置起始和终点Y坐标,接着绘制直线
- 五、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库,好比下面这个动画(漂浮的大象,还会眨眼):
还有白圈扩散波纹的动画,若是让你来作,你会怎么作?
- 一、帧动画:须要添加大量图片(尺寸适配),势必会致使APK体积暴涨;
- 二、Gif:Gif图占用空间较大,且需适配多种屏幕,影响同上;
- 三、属性动画 + 图片 + SVG:繁琐且不易维护,稍做修改可能就要推倒重来;
用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用到的全部「基础逆向操做」谢谢~
对了,混掘金也挺久了,继白嫖笔记本后,前些天又白嫖了一个鼠标垫,感恩!
意思意思送「一本本身写的Python爬虫入门书」吧,评论区留言抽,包邮,下周五抽~
参考文献: