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


0x0、久违的碎碎念


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

郑重声明
  html

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

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


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

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

这个页面显示全部课程,而后能够滑动,滑动的同时背景也要跟着动…git

听得我是:???github

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

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

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

Yo~ 游戏通关类的学习APP耶,记得很久之前在一款英语APP上也看到这种页面,不过人家用Cocos2d作的,若是这个也是这样,就无法作了,先来辨别「页面是否是原生写的」。性能优化


0x二、如何辨别页面是否是原生写的


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

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

若是出现以下所示的边框和线:

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

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

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

那种感受就好像:

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

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

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

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

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


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


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

  • 一、应用包名:com.knowbox.en
  • 二、当前页面名称为:MainActivity
  • 三、当前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方法不被调用(从而屏蔽用户滑动与点击)。

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


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


一、列表内容布局

无脑搬运布局,只是外层用的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


三个背景图用一个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


三、斜虚线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了。


四、前面的Adapter


直接定位到MainHomeworkAdapter

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

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

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

而StartHolder和EndHolder则比较简单:

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

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

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

行吧,算是偷取完成了~


0x五、偷:②字体TextView


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

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

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


0x六、偷:③ Airbnb的Lottie

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

还有白圈扩散波纹的动画,若是让你来作,你会怎么作?

  • 一、帧动画:须要添加大量图片(尺寸适配),势必会致使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直接使用。

更多使用说明可见:

搬运:

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

运行效果以下:


0x七、小结


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

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


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


参考文献:

相关文章
相关标签/搜索