一个开发仔的日常离不开和产品经理的Speak,但大多数时候哔哔一堆,不如一句“直接说抄哪个APP”。借(chao)鉴(xi)是门手艺活,简单的瞄一下,点几下,可能就知道大概的实现逻辑了,但是「知道 != 写得出来」,一看就会,一做就废是常事。既然自己写不出来,那就去「偷」!是的,你没听错,去偷别人的代码。“盗亦有道”:掌握适当的技巧可以帮我们更快,更顺利得偷到别人的APP代码,本节以偷「掘金的消息卡片代码」为例,讲解一波。
0x1、缘起
发完《Kotlin刨根问底——你真的了解Kotlin中的空安全吗?》这篇文章后,习惯性地把文章链接往我的小破群里一丢,接着套用模(mú)板:刚撸的文章,讲xxx的,取需。简短的一句,如无病呻吟,换来几句「看不懂,但是,群主牛逼的商业互吹」以及「十位数的阅读量」
群分享完了,接着小号分享朋友圈,分享时,看到「消息卡片」的这个选项:
点击生成后的卡片还挺精美,啧啧啧,正所谓:爱美之心,人皆有之~
这种分享生成卡片图的操作很常见,常用于各种导流,比如抖音的抖音码:
感觉可以给「抠腚早报速读」也搞一个,毕竟 花里胡哨的图片 比 没有灵魂的文字和链接 有趣得多。行吧,偷一波「掘金消息卡片的代码」:
其实吧,实现原理还挺简单的(噗嗤~):
写一个卡片页面的布局,然后调用 View.draw() 实现View截图Bitmap,把Bitmap保存到相册。
接着的内容,大家配合下我的演出,开启装傻模式吧!
0x2、图由谁来生成?客户端 VS 服务端
假装客户端和服务端在那里激烈甩锅:
争论信息图片「由服务端生成的」还是「由客户端生成的」状:
- 客户端:我丢,写个接口,我调用的时候给我生成卡片,直接显示,美滋滋啊!
- 服务端:美毛线,吃太饱的一直点生成,接口一直调?而且生成要时间啊!
- 客户端:缓存啊,生成过的缓存起来,生成过的直接返回,还要我教,菜虚鲲?
- 服务端:你这样浪费资源啊,还要找个服务器放这些图,请求生成卡片的并发量 太大后台会炸的,一个简单的生成页面,搞那么复杂?高内聚低耦合,你懂不懂?
此时我化身一个 和事佬 出现:
哔哔那么多,验证下,看别人是怎么做的不就好了,最简单的方法,手机依次点击:
设置 -> 开发者选项 -> 勾选显示布局边界
接着:
回到掘金点击消息卡片 -> 截图 -> 点击保存 -> 打开图库
可以看到下面这两个图片:
是的,右侧生成的卡片有「布局边界」,就是客户端生成的!除此之外,还可以通过抓包来验证。
抓包区间是「打开信息卡片前」和「点击保存后」,看下是否有拉取卡片图的请求。
熟练的打开Fidder,安装证书,打开WIFI手动设置下代理:主机ip,8888,却发现抓不了HTTPS包。即使换成Charles,Wireshark等其他抓包工具也抓不到,原因是:
Android 7.0(Nougat,牛轧糖)开始,Android更改了对用户安装证书的默认信任行为,应用程序「只信任系统级别的CA」。
对此,如果是自己写的APP想抓HTTPS的包,可以在 res/xml
目录下新建一个network_security_config.xml
文件,复制粘贴如下内容:
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" overridePins="true" /> <!--信任系统证书-->
<certificates src="user" overridePins="true" /> <!--信任用户证书-->
</trust-anchors>
</base-config>
</network-security-config>
接着**AndroidManifest.xml
文件中新增networkSecurityConfig**属性引用xml文件,如下:
<?xml version="1.0" encoding="utf-8"?>
<manifest ... >
<application android:networkSecurityConfig="@xml/network_security_config"
... >
...
</application>
</manifest>
调试期为了方便自己抓包可以加上,发版本的时候,记得删掉哦!!!当然,大部分时候都是想抓别人APP的HTTPS包,最简单的两种处理方法:
- 1、「系统降级」即采用Android 7.0以下手机,比如笔者的抓包鸡魅蓝E2就是Android 6.0。
- 2、「抓越狱苹果鸡」
稍微复杂点,也是比较常见的方法「手机Root,把证书安装到系统证书中」,具体操作步骤如下:
# 打开终端,输入下述命令把 cer或者der 证书转换为pem格式
openssl x509 -inform der -in xxx.cer -out xxx.pem
# 证书重命名,命名规则为:<Certificate_Hash>.<Number>
# Certificate_Hash:表示证书文件的hash值
# Number:为了防止证书文件的hash值一致而增加的后缀
# 通过下述命令计算出hash值
openssl x509 -subject_hash_old -in Fiddler.pem |head -1
# 重命名,hash值是上面命令执行输出的hash值,比如269953fb
mv Fiddler.pem <hash>.0
# adb push命令把文件复制到内部存储
adb push <hash>.0 /sdcard/
adb shell # 启动命令行交互
su # 获得超级用户权限
mount -o remount,rw /system # 将/system目录重新挂载为可读写
cp /sdcard/<hash>.0 /system/etc/security/cacerts/ # 把文件复制过去
cd /system/etc/security/cacerts/ # 来到目录下
chmod 644 <hash>.0 # 修改文件权限
# 重启设备
adb reboot
重启后,看下能否抓到HTTPS包就知道是否安装成功,也可以到设置 -> 安全性和位置信息 -> 加密与凭据 -> 信任的凭据 -> 系统 里找找自己刚安装的证书(不同手机路径可能不一样)。
其他抓包工具也是如法炮制,除此还有一种成本更高的方法:「二次打包APK」,不过现在反编译越来越难,不一定能打包成功,当然也说说流程:
- ① 通过apktool反编译apk;
- ② 在res/xml目录中创建文件network_security_config.xml;
- ③ AndroidManifest.xml添加android:networkSecurityConfig属性;
- ④ 重新打包并自签名APK
说回正题,抓包,证明消息卡片不是请求后台获取到的:
在点击消息卡片以及到保存这一步,只下载了头图,而非卡片图,大概能猜测到:
打开页面时传入标题,内容简介,文章头图,链接等,然后生成消息卡片。
而view生成截图的方式有两种:分别是调用View的 getDrawingCache() 和 draw() 方法,不过前者已经Deprecated(过时),点开源码可以看到这样的注释:
继续假装不知道原理,接着把布局给抠出来。
0x3、掘金色的渐变圆角背景图——Apktool获得资源素材
假装不会实现这个背景图,使用「Apktool」反编译一波apk,直接拿资源文件,工具包可自己百度或 公号回复001 获取,如果反编译出现如下错误信息:
可尝试更新一波apktool.jar的版本,到:bitbucket.org/iBotPeaches… 下载最新版的Jar包替换即可。接着键入下述命令反编译apk:
apktool.bat d -f xxx.apk
坐等编译成功:
接着打开编译后的项目的drawable目录,搜索:bg_,可以看到:
盲猜bg_message_card.xml,打开看看:
复制到工程中,稍微调整下,看下预览效果:
可以的,接着把用到的图片素材找出来,复制到工程中,接着我们来堆砌布局。
0x4、布局怎么堆——开发者助手 + UETool
先来了解布局的层次,最简单的方法:通过adb命令来查看:
adb shell dumpsys activity top > info.txt
上述命令会导出activity的堆栈信息到info.txt文件中,搜索应用包名,可定位到当前显示的Activity:
往下一点,可以看到View的层次结构:
从这里就可以看到当前这个页面都是由哪些控件堆砌而成的, 不过可能不是很直观,接着安利一个Android调试工具:「开发者助手」(可到酷安搜索或 公号回复002获取),需要Root权限!!!直接可以看到包名,版本,当前Activity,Fragment,界面资源分析等信息。
点下界面资源分析,页面组成一清二楚。
知道布局是由哪些控件堆砌而成的,是远远不够的,我们还需要知道控件具体是怎么堆的。即:宽高多少,margin和padding多少,字体多大,颜色值,是否加粗等等这些信息。一种比较低效的方法是:
手机截图,发送到电脑,用PxCook之类的工具打开,然后用尺子去量尺寸,取色工具取色。
可以是不可以,不过有点捞啊,有没有更便捷的方法呢?答案肯定是有,再安利一个调试工具:UETool,一个移动端页面调试工具:
不过这个工具,只能 在自己的项目中集成,并不能用来调教别人的APP,需要上扩展版:VirtualUETool
Tips:Virtual App 是著名的黑产神器,App虚拟化引擎,可以在其中创建虚拟空间,然后在虚拟空间里安装运行卸载APP,最常见的使用场景就是应用分身,之前是开源的,不过看README.md貌似开始商业化了...
VirtualUETool 用法比较简单,「捕捉控件」可以看到控件的一些属性信息,「相对位置」可以看控件宽高和与其他控件的间距,「网格栅栏」可以用来看控件是否对齐,「布局层级」以3D模式查看层级。使用示例如下:
边框,参数这些都有了,堆布局就不是什么大问题了~
0x5、用边角料——堆砌一个不完整的页面
这里没有直接用它的布局文件,而是参照着自己另外写了一个,利用前面获取的一些边角料。
布局文件:activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FF333333"
tools:context=".MainActivity">
<android.support.constraint.ConstraintLayout
android:id="@+id/cly_share_bar"
android:layout_width="0dp"
android:layout_height="98dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:background="#FFEEEEEE">
<android.support.v7.widget.AppCompatImageView
android:id="@+id/iv_share_wx"
android:layout_width="0dp"
android:layout_height="44dp"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@id/iv_share_pyq"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/tv_share_wx"
android:scaleType="fitCenter"
android:src="@drawable/share_wechat"/>
<android.support.v7.widget.AppCompatTextView
android:id="@+id/tv_share_wx"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintLeft_toLeftOf="@id/iv_share_wx"
app:layout_constraintRight_toRightOf="@id/iv_share_wx"
app:layout_constraintTop_toBottomOf="@id/iv_share_wx"
app:layout_constraintBottom_toBottomOf="parent"
android:textSize="12sp"
android:textColor="#8A000000"
android:text="微信"/>
<android.support.v7.widget.AppCompatImageView
android:id="@+id/iv_share_pyq"
android:layout_width="0dp"
android:layout_height="44dp"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintLeft_toRightOf="@id/iv_share_wx"
app:layout_constraintRight_toLeftOf="@id/iv_share_qq"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/tv_share_pyq"
android:scaleType="fitCenter"
android:src="@drawable/share_circle"/>
<android.support.v7.widget.AppCompatTextView
android:id="@+id/tv_share_pyq"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintLeft_toLeftOf="@id/iv_share_pyq"
app:layout_constraintRight_toRightOf="@id/iv_share_pyq"
app:layout_constraintTop_toBottomOf="@id/iv_share_pyq"
app:layout_constraintBottom_toBottomOf="parent"
android:textSize="12sp"
android:textColor="#8A000000"
android:text="朋友圈"/>
<android.support.v7.widget.AppCompatImageView
android:id="@+id/iv_share_qq"
android:layout_width="0dp"
android:layout_height="44dp"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintLeft_toRightOf="@id/iv_share_pyq"
app:layout_constraintRight_toLeftOf="@id/iv_share_wb"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/tv_share_qq"
android:scaleType="fitCenter"
android:src="@drawable/share_qq"/>
<android.support.v7.widget.AppCompatTextView
android:id="@+id/tv_share_qq"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintLeft_toLeftOf="@id/iv_share_qq"
app:layout_constraintRight_toRightOf="@id/iv_share_qq"
app:layout_constraintTop_toBottomOf="@id/iv_share_qq"
app:layout_constraintBottom_toBottomOf="parent"
android:textSize="12sp"
android:textColor="#8A000000"
android:text="QQ"/>
<android.support.v7.widget.AppCompatImageView
android:id="@+id/iv_share_wb"
android:layout_width="0dp"
android:layout_height="44dp"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintLeft_toRightOf="@id/iv_share_qq"
app:layout_constraintRight_toLeftOf="@id/iv_share_save"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/tv_share_wb"
android:scaleType="fitCenter"
android:src="@drawable/share_weibo"/>
<android.support.v7.widget.AppCompatTextView
android:id="@+id/tv_share_wb"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintLeft_toLeftOf="@id/iv_share_wb"
app:layout_constraintRight_toRightOf="@id/iv_share_wb"
app:layout_constraintTop_toBottomOf="@id/iv_share_wb"
app:layout_constraintBottom_toBottomOf="parent"
android:textSize="12sp"
android:textColor="#8A000000"
android:text="微博"/>
<android.support.v7.widget.AppCompatImageView
android:id="@+id/iv_share_save"
android:layout_width="0dp"
android:layout_height="44dp"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintLeft_toRightOf="@id/iv_share_wb"
app:layout_constraintRight_toLeftOf="@id/iv_share_other"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/tv_share_save"
android:scaleType="fitCenter"
android:src="@drawable/share_save"/>
<android.support.v7.widget.AppCompatTextView
android:id="@+id/tv_share_save"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintLeft_toLeftOf="@id/iv_share_save"
app:layout_constraintRight_toRightOf="@id/iv_share_save"
app:layout_constraintTop_toBottomOf="@id/iv_share_save"
app:layout_constraintBottom_toBottomOf="parent"
android:textSize="12sp"
android:textColor="#8A000000"
android:text="保存"/>
<android.support.v7.widget.AppCompatImageView
android:id="@+id/iv_share_other"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintLeft_toRightOf="@id/iv_share_save"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/tv_share_other"
android:scaleType="fitCenter"
android:src="@drawable/share_others"/>
<android.support.v7.widget.AppCompatTextView
android:id="@+id/tv_share_other"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintLeft_toLeftOf="@id/iv_share_other"
app:layout_constraintRight_toRightOf="@id/iv_share_other"
app:layout_constraintTop_toBottomOf="@id/iv_share_other"
app:layout_constraintBottom_toBottomOf="parent"
android:textSize="12sp"
android:textColor="#8A000000"
android:text="保存"/>
</android.support.constraint.ConstraintLayout>
<android.support.v4.widget.NestedScrollView
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginBottom="2dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/cly_share_bar"
android:background="@drawable/bg_message_card">
<android.support.constraint.ConstraintLayout
android:id="@+id/cly_content"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<android.support.constraint.ConstraintLayout
android:id="@+id/cly_card"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginEnd="14dp"
android:layout_marginTop="26dp"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:background="@drawable/shape_bg_content">
<android.support.v7.widget.AppCompatImageView
android:id="@+id/iv_avatar"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginLeft="16dp"
android:layout_marginTop="22dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<TextView
android:id="@+id/tv_level"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
app:layout_constraintLeft_toRightOf="@id/iv_avatar"
app:layout_constraintTop_toTopOf="@id/iv_avatar"
android:textSize="16sp"
android:drawablePadding="5dp"
android:textColor="#FF1C1C1E"/>
<android.support.v7.widget.AppCompatTextView
android:id="@+id/tv_desc"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintLeft_toLeftOf="@id/tv_level"
app:layout_constraintBottom_toBottomOf="@id/iv_avatar"
android:textSize="12sp"
android:textColor="#FF8A9AA9"/>
<android.support.v7.widget.AppCompatImageView
android:id="@+id/iv_article_hover"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginTop="22dp"
app:layout_constraintLeft_toLeftOf="@id/iv_avatar"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/iv_avatar"
android:adjustViewBounds="true"/>
<android.support.v7.widget.AppCompatTextView
android:id="@+id/tv_article_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
app:layout_constraintLeft_toLeftOf="@id/iv_article_hover"
app:layout_constraintRight_toRightOf="@id/iv_article_hover"
app:layout_constraintTop_toBottomOf="@id/iv_article_hover"
android:textSize="20sp"
android:textStyle="bold"
android:lineSpacingExtra="4dp"
android:textColor="#FF1C1C1E"
android:text=""/>
<android.support.v7.widget.AppCompatTextView
android:id="@+id/tv_article_summary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="14dp"
android:layout_marginRight="14dp"
android:layout_marginTop="9dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_article_title"
android:textSize="16sp"
android:lineSpacingExtra="4dp"
android:textColor="#FF1C1C1E"
android:text=""/>
<android.support.v7.widget.AppCompatImageView
android:id="@+id/iv_article_qrcode"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_marginTop="18dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_article_summary"
android:scaleType="fitCenter"
android:src="@drawable/ic_qr_code"/>
<android.support.v7.widget.AppCompatTextView
android:id="@+id/tv_article_tips"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:layout_marginBottom="12dp"
android:paddingBottom="12dp"
app:layout_constraintLeft_toLeftOf="@id/iv_article_qrcode"
app:layout_constraintRight_toRightOf="@id/iv_article_qrcode"
app:layout_constraintTop_toBottomOf="@id/iv_article_qrcode"
app:layout_constraintBottom_toBottomOf="parent"
android:textSize="11sp"
android:textColor="#FF1C1C1E"
android:text="长按识别二维码"/>
</android.support.constraint.ConstraintLayout>
<android.support.v7.widget.AppCompatImageView
android:id="@+id/iv_adaptive_logo"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginTop="14dp"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/tv_slogan"
app:layout_constraintTop_toBottomOf="@id/cly_card"
android:scaleType="fitCenter"
android:src="@drawable/adaptive_logo"/>
<android.support.v7.widget.AppCompatTextView
android:id="@+id/tv_slogan"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toEndOf="@+id/iv_adaptive_logo"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/iv_adaptive_logo"
app:layout_constraintBottom_toBottomOf="@id/iv_adaptive_logo"
android:textSize="12dp"
android:textColor="#FFFCFCFC"
android:text="掘金 · 一个帮助开发者成长的技术社区"/>
<android.support.v7.widget.AppCompatTextView
android:id="@+id/tv_host"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="1dp"
android:layout_marginBottom="24dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_slogan"
app:layout_constraintBottom_toBottomOf="parent"
android:textSize="12dp"
android:textColor="#FFFCFCFC"
android:text="juejin.im"/>
</android.support.constraint.ConstraintLayout>
</android.support.v4.widget.NestedScrollView>
</android.support.constraint.ConstraintLayout>
界面文件:MainActivity.kt
package com.coderpig.kttest
import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.CircleCrop
import com.bumptech.glide.request.RequestOptions
import kotlinx.android.synthetic.main.activity_main.*
class MainActivity : AppCompatActivity() {
private val authorAvatarUrl = "https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/5/10/16a9fc6bbb83e12e~tplv-t2oaga2asx-image.image"
private val authorNickName = "coder-pig"
private val authorDesc = "网管@抠腚网咖"
private val authorLevel = 1
private val articleCoverUrl =
"https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/7/24/16c22e09ebcc9819~tplv-t2oaga2asx-image.image"
private val articleTitle = "Kotlin刨根问底——你真的了解Kotlin中的空安全吗?"
private val articleSummary =
"每个人的时间都是有限的,一旦做出学习某块知识的选择,意味着付出了暂时无法学习其他知识的机会成本,需要取舍。不可能等什么都学会了再去面试,学完得猴年马月,而且技术,是学不完的… 初次接触Kotlin已是三年前,在上家公司用Kotlin重构了平板的应用市场和电台APP。说来惭愧,至…"
private val articleUrl = "https://juejin.cn/post/1"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
Glide.with(this).load(authorAvatarUrl).apply(RequestOptions.bitmapTransform(CircleCrop())).into(iv_avatar)
tv_level.text = authorNickName
val rightDrawable = when (authorLevel) {
1 -> getDrawable((R.drawable.ic_user_lv1))
2 -> getDrawable((R.drawable.ic_user_lv2))
3 -> getDrawable((R.drawable.ic_user_lv3))
4 -> getDrawable((R.drawable.ic_user_lv4))
5 -> getDrawable((R.drawable.ic_user_lv5))
6 -> getDrawable((R.drawable.ic_user_lv6))
7 -> getDrawable((R.drawable.ic_user_lv7))
8 -> getDrawable((R.drawable.ic_user_lv8))
else -> null
}
rightDrawable?.setBounds(0, 0, rightDrawable.minimumWidth, rightDrawable.minimumHeight)
tv_level.setCompoundDrawables(null, null, rightDrawable, null)
tv_desc.text = authorDesc
tv_article_title.text = articleTitle
tv_article_summary.text = articleSummary
Glide.with(this).load(articleCoverUrl).into(iv_article_hover)
}
}
运行效果图如下:
哈哈,像不像,真的不是截掘金哈,替换一波文章相关的信息,运行下看看:
细心的你应该能发现这个模糊的二维码(直接用的截图),以及右上角缺失了的文章标签,假装不知道二维码可以用zxing实现,看看掘金是怎么做的?
0x6、君子爱码,盗之有道——Jadx反编译
1、偷生成二维码图片的代码
在开发者助手那里可以看到「未知加固」,一般就是没有加固,不用脱壳美滋滋,Jadx反编译一波源码(jadx直接反编译apk很容易直接卡死,笔者写了个Python的批处理脚本,取需:github.com/coder-pig/C…),反编译后用Android Studio打开反编译后的项目,记得顺带把前面apktool反编译出来的res资源文件夹也丢进去!
接着全局搜索文件CommonActivity.java,代码里搜下setContentView,可以看到:
这里和沉浸式状态栏有关,兼容Android 5.0以下,布局如下大同小异:
自定义了一个StatusBarView状态栏和一个帧布局容器FixInsetsFrameLayout,中间塞个toolbar,这里主要关注这个容器,布局id:fragment_container,八九不离十是用来塞Fragment的,搜下:
getSupportFragmentManager().beginTransaction().replace
可以看到:
噢,两种创建方法耶:
- 1、直接Intent传FRAGMENT_NAME创建
- 2、通过ARouter创建
第一种见得多了,第二种用的是阿里的ARouter路由,点进去**ServiceFactory.getInstance().getFragment()**方法:
就是try里面包着的这一句,去ARouter的Github仓库就可以翻到混淆前的样子是:
Fragment fragment = (Fragment) ARouter.getInstance().build("/xxx/xxxfragment").navigation();
啧啧啧,怪不得开发者助手那里无法检测当前Fragment,行吧,我们需要找到这个fragment的路径,在CommonActivity.java这个文件中显然是很难继续下去的:
解当然也是有解的,动态调试smali或者xposed写个简单插件打印日志,不过有点繁琐,换种姿势吧。先明确下现在的目标:
找到消息卡片的布局!!!
em...发现卡片底部有一句掘金的slogan:一个帮助开发者成长的技术社区,全局搜下?
23333,直指 fragment_entry_pin_card.xml布局,打开看看:
圈住的部分分别是二维码对应的控件和右上角标签,接着全局搜R.layout.fragment_entry_pin_card
定位到了PreviewEntryPinCardFragment这个类,就是消息卡片对应的Fragment,接着搜显示二维码控件的id:iv_qrcode:
定位到BitmapUtils类的**create2DCode()**方法:
导包处可以看到用到了google的zxing库:
复制粘贴,转一波Kotlin,这里Hashtable需要明确传入类型:
接着显示二维码的控件调用 setImageBitmap() 设置一波,结果如下:
可以,很舒服,你可能对这里的**-16777216**有疑问,其实就是一个十进制的颜色值,代码转下十六进制:
print(String.format("%08x",-16777216))
打印:ff000000,即不透明黑色,对应属性Color.Black,偷二维码生成代码任务完成!
2、偷自定义标签控件的代码
接着到右上角的文章标签了,在布局文件里看到这个自定义控件im.juejin.android.base.views.labelview.LabelView,搜下LabelView.java文件,开偷,如果你有一定的Kotlin语法基础和自定义View基础,偷起来还是很是不难的,留意下这个东西:
就是自定义属性,反编译后的attrs.xml里是找不到这个LabelView的,需要自己自定义一个,定义declare-styleable标签,把相关属性从反编译后的attrs.xml中选择性复制,比如这里:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="LabelView">
<attr name="font" format="reference"/>
<attr name="fontWeight" format="integer"/>
<attr name="foregroundInsidePadding" format="boolean"/>
<attr name="il_max_length" format="integer"/>
<attr name="il_hint" format="string"/>
<attr name="lv_text" format="string"/>
<attr name="lv_text_color" format="color"/>
<attr name="lv_text_size" format="dimension"/>
<attr name="lv_text_bold" format="boolean"/>
<attr name="lv_text_all_caps" format="boolean"/>
<attr name="lv_background_color" format="color"/>
<attr name="lv_min_size" format="dimension"/>
<attr name="lv_padding" format="dimension"/>
<attr name="lv_gravity">
<enum name="BOTTOM_LEFT" value="83"/>
<enum name="BOTTOM_RIGHT" value="85"/>
<enum name="TOP_LEFT" value="51"/>
<enum name="TOP_RIGHT" value="53"/>
</attr>
<attr name="lv_fill_triangle" format="boolean" />
</declare-styleable>
</resources>
接着甚至连实现原理都不用去看,无脑复制代码进项目中,自动Java转Kotlin,处理一波语法问题和删除无关代码,调整后的LabelView代码如下:
package com.coderpig.kttest
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.util.AttributeSet
import android.view.View
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
class LabelView : View {
private var mBackgroundColor: Int = 0
private var mBackgroundPaint: Paint = Paint(1)
private var mFillTriangle: Boolean = false
private var mGravity: Int = 0
private var mMinSize: Float = 0.0f
private var mPadding: Float = 0.0f
private var mPath: Path = Path()
private var mTextAllCaps: Boolean = false
private var mTextBold: Boolean = false
private var mTextColor: Int = 0
private var mTextContent: String = ""
private var mTextPaint: Paint = Paint(1)
private var mTextSize: Float = 0.0f
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs, 0) {
obtainAttributes(context, attrs)
this.mTextPaint.textAlign = Paint.Align.CENTER
}
private fun obtainAttributes(context: Context, attributeSet: AttributeSet?) {
val obtainStyledAttributes = context.obtainStyledAttributes(attributeSet, R.styleable.LabelView)
this.mTextContent = obtainStyledAttributes.getString(R.styleable.LabelView_lv_text) as String
this.mTextColor = obtainStyledAttributes.getColor(
R.styleable.LabelView_lv_text_color, Color.parseColor("#FFFFFF")
)
this.mTextSize = obtainStyledAttributes.getDimension(R.styleable.LabelView_lv_text_size, sp2px(11.0f).toFloat())
this.mTextBold = obtainStyledAttributes.getBoolean(R.styleable.LabelView_lv_text_bold, true)
this.mTextAllCaps = obtainStyledAttributes.getBoolean(R.styleable.LabelView_lv_text_all_caps, true)
this.mFillTriangle = obtainStyledAttributes.getBoolean(R.styleable.LabelView_lv_fill_triangle, false)
this.mBackgroundColor =
obtainStyledAttributes.getColor(R.styleable.LabelView_lv_background_color, Color.parseColor("#FF4081"))
this.mMinSize = obtainStyledAttributes.getDimension(
R.styleable.LabelView_lv_min_size,
if (this.mFillTriangle) dp2px(35.0f).toFloat() else dp2px(50.0f).toFloat()
)
this.mPadding = obtainStyledAttributes.getDimension(R.styleable.LabelView_lv_padding, dp2px(3.5f).toFloat())
this.mGravity = obtainStyledAttributes.getInt(R.styleable.LabelView_lv_gravity, 51)
obtainStyledAttributes.recycle()
}
fun setTextColor(i: Int) { this.mTextColor = i; invalidate() }
fun setText(str: String) { this.mTextContent = str; invalidate() }
fun setTextSize(f: Float) { this.mTextSize = sp2px(f).toFloat(); invalidate() }
fun setTextBold(z: Boolean) { this.mTextBold = z; invalidate() }
fun setFillTriangle(z: Boolean) { this.mFillTriangle = z; invalidate() }
fun setTextAllCaps(z: Boolean) { this.mTextAllCaps = z; invalidate() }
fun setBgColor(i: Int) { this.mBackgroundColor = i; invalidate() }
fun setMinSize(f: Float) { this.mMinSize = dp2px(f).toFloat(); invalidate() }
fun setPadding(f: Float) { this.mPadding = dp2px(f).toFloat(); invalidate() }
fun setGravity(i: Int) { this.mGravity = i }
fun getText(): String = this.mTextContent
fun getTextColor() = this.mTextColor
fun getTextSize() = this.mTextSize
fun isTextBold() = this.mTextBold
fun isFillTriangle() = this.mFillTriangle
fun isTextAllCaps() = this.mTextAllCaps
fun getBgColor() = this.mBackgroundColor
fun getMinSize() = this.mMinSize
fun getPadding() = this.mPadding
fun getGravity() = this.mGravity
public override fun onDraw(canvas: Canvas) {
val height = height
this.mTextPaint.color = this.mTextColor
this.mTextPaint.textSize = this.mTextSize
this.mTextPaint.isFakeBoldText = this.mTextBold
this.mBackgroundPaint.color = this.mBackgroundColor
val descent = this.mTextPaint.descent() - this.mTextPaint.ascent()
if (!this.mFillTriangle) {
val sqrt = (this.mPadding * 2.0f + descent).toDouble() * sqrt(2.0)
when {
this.mGravity == 51 -> {
this.mPath.reset()
this.mPath.moveTo(0.0f, (height.toDouble() - sqrt).toFloat())
this.mPath.lineTo(0.0f, height.toFloat())
this.mPath.lineTo(height.toFloat(), 0.0f)
this.mPath.lineTo((height.toDouble() - sqrt).toFloat(), 0.0f)
this.mPath.close()
canvas.drawPath(this.mPath, this.mBackgroundPaint)
drawText(height, -45.0f, canvas, descent, true)
}
this.mGravity == 53 -> {
this.mPath.reset()
this.mPath.moveTo(0.0f, 0.0f)
this.mPath.lineTo(sqrt.toFloat(), 0.0f)
this.mPath.lineTo(height.toFloat(), (height.toDouble() - sqrt).toFloat())
this.mPath.lineTo(height.toFloat(), height.toFloat())
this.mPath.close()
canvas.drawPath(this.mPath, this.mBackgroundPaint)
drawText(height, 45.0f, canvas, descent, true)
}
this.mGravity == 83 -> {
this.mPath.reset()
this.mPath.moveTo(0.0f, 0.0f)
this.mPath.lineTo(0.0f, sqrt.toFloat())
this.mPath.lineTo((height.toDouble() - sqrt).toFloat(), height.toFloat())
this.mPath.lineTo(height.toFloat(), height.toFloat())
this.mPath.close()
canvas.drawPath(this.mPath, this.mBackgroundPaint)
drawText(height, 45.0f, canvas, descent, false)
}
this.mGravity == 85 -> {
this.mPath.reset()
this.mPath.moveTo(0.0f, height.toFloat())
this.mPath.lineTo(sqrt.toFloat(), height.toFloat())
this.mPath.lineTo(height.toFloat(), sqrt.toFloat())
this.mPath.lineTo(height.toFloat(), 0.0f)
this.mPath.close()
canvas.drawPath(this.mPath, this.mBackgroundPaint)
drawText(height, -45.0f, canvas, descent, false)
}
}
} else if (this.mGravity == 51) {
this.mPath.reset()
this.mPath.moveTo(0.0f, 0.0f)
this.mPath.lineTo(0.0f, height.toFloat())
this.mPath.lineTo(height.toFloat(), 0.0f)
this.mPath.close()
canvas.drawPath(this.mPath, this.mBackgroundPaint)
drawTextWhenFill(height, -45.0f, canvas, true)
} else if (this.mGravity == 53) {
this.mPath.reset()
this.mPath.moveTo(height.toFloat(), 0.0f)
this.mPath.lineTo(0.0f, 0.0f)
this.mPath.lineTo(height.toFloat(), height.toFloat())
this.mPath.close()
canvas.drawPath(this.mPath, this.mBackgroundPaint)
drawTextWhenFill(height, 45.0f, canvas, true)
} else if (this.mGravity == 83) {
this.mPath.reset()
this.mPath.moveTo(0.0f, height.toFloat())
this.mPath.lineTo(0.0f, 0.0f)
this.mPath.lineTo(height.toFloat(), height.toFloat())
this.mPath.close()
canvas.drawPath(this.mPath, this.mBackgroundPaint)
drawTextWhenFill(height, 45.0f, canvas, false)
} else if (this.mGravity == 85) {
this.mPath.reset()
this.mPath.moveTo(height.toFloat(), height.toFloat())
this.mPath.lineTo(0.0f, height.toFloat())
this.mPath.lineTo(height.toFloat(), 0.0f)
this.mPath.close()
canvas.drawPath(this.mPath, this.mBackgroundPaint)
drawTextWhenFill(height, -45.0f, canvas, false)
}
}
private fun drawText(i: Int, f: Float, canvas: Canvas, f2: Float, z: Boolean) {
canvas.save()
canvas.rotate(f, i.toFloat() / 2.0f, i.toFloat() / 2.0f)
canvas.drawText(
if (this.mTextAllCaps) this.mTextContent.toUpperCase() else this.mTextContent,
(paddingLeft + (i - paddingLeft - paddingRight) / 2).toFloat(),
(i / 2).toFloat() - (this.mTextPaint.descent() + this.mTextPaint.ascent()) / 2.0f + if (z) -(this.mPadding * 2.0f + f2) / 2.0f else (this.mPadding * 2.0f + f2) / 2.0f,
this.mTextPaint
)
canvas.restore()
}
private fun drawTextWhenFill(i: Int, f: Float, canvas: Canvas, z: Boolean) {
canvas.save()
canvas.rotate(f, i.toFloat() / 2.0f, i.toFloat() / 2.0f)
canvas.drawText(
if (this.mTextAllCaps) this.mTextContent.toUpperCase() else this.mTextContent,
(paddingLeft + (i - paddingLeft - paddingRight) / 2).toFloat(),
(i / 2).toFloat() - (this.mTextPaint.descent() + this.mTextPaint.ascent()) / 2.0f + if (z) (-i / 4).toFloat() else (i / 4).toFloat(),
this.mTextPaint
)
canvas.restore()
}
/* access modifiers changed from: protected */
public override fun onMeasure(i: Int, i2: Int) {
val measureWidth = measureWidth(i)
setMeasuredDimension(measureWidth, measureWidth)
}
private fun measureWidth(i: Int): Int {
val mode = MeasureSpec.getMode(i)
val size = MeasureSpec.getSize(i)
if (mode == 1073741824) {
return size
}
val paddingLeft = paddingLeft + paddingRight
this.mTextPaint.color = this.mTextColor
this.mTextPaint.textSize = this.mTextSize
var measureText =
((paddingLeft + this.mTextPaint.measureText(this.mTextContent + "").toInt()).toDouble() * sqrt(2.0)).toInt()
if (mode == Integer.MIN_VALUE) {
measureText = min(measureText, size)
}
return max(this.mMinSize.toInt(), measureText)
}
private fun dp2px(f: Float) = (resources.displayMetrics.density * f + 0.5f).toInt()
private fun sp2px(f: Float) = (resources.displayMetrics.scaledDensity * f + 0.5f).toInt()
}
布局直接添加这个控件:
<com.coderpig.kttest.LabelView
xmlns:lv="http://schemas.android.com/apk/res-auto"
android:layout_width="60dp"
android:layout_height="60dp"
android:paddingLeft="10dip"
android:paddingRight="10dip"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
lv:lv_text=" 文章 "
lv:lv_text_color="#ffffffff"
lv:lv_text_size="11.199982sp"
lv:lv_background_color="#ffdfdfdf"
lv:lv_gravity="TOP_RIGHT"
lv:lv_fill_triangle="false"/>
运行下看下效果(对比掘金生成的和我实现的效果):
3、偷生成信息卡片截图的代码
行吧,就剩下最后生成信息卡片截图的代码咯,在上面的PreviewEntryPinCardFragment.java文件中并没有找到底下这个分享栏。分享栏应该是写到另一个布局文件中了,这里用一个取巧的操作,直接全局搜保存按钮的文件名:share_save,记得勾选xml类型,可以更快定位到:
打开fragment_message_card.xml:
行吧,就是我们想要的内容,全局搜:R.layout.fragment_message_card,勾选java文件:
直指 ActivityShareFragment.java,接着搜save,定位到点击 ,
猜测这里做了两个操作:
- 1、调用BitmapUtils类的saveBitmap2file()方法保存截图;
- 2、调用FileUtils类的notifyGallery()通知图库更新;
接着打开BitmapUtils类,定位到 saveBitmap2file() 方法:
这段代码只是:把Bitmap保存为JPEG图片而已,然后,前面的Bitmap哪来的?对应参数r1:
通过CommonMessageCardFragment类的getBitmap()方法获得,打开类定位到getBitmap()方法:
抽象类和抽象方法???看下前面的PreviewEntryPinCardFragment是不是继承了这个类:
果然,这里把布局视图作为参数传入 ViewExKt.e() 方法,跟:
卧槽,水到渠成啊,图片的生成过程一清二楚啊!接着把 图片路径生成规律 和 通知图库 的部分也抠出来把。
SD.getGalleryDir():获得图库路径:
MD5Util.encrypt():MD5加密下链接:
最后FileUtils.notifyGallery():发送广播通知图库更新。
啧啧啧,材料齐全,开始组装偷来的代码,整合后的代码如下:
工具类:Utils.kt
package com.coderpig.kttest
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Environment
import java.io.FileOutputStream
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
// 把Bitmap保存为图片
fun saveBitmap2file(bitmap: Bitmap, str: String): Boolean {
val compressFormat = Bitmap.CompressFormat.JPEG
return try {
val fileOutputStream = FileOutputStream(str)
val compress = bitmap.compress(compressFormat, 100, fileOutputStream)
fileOutputStream.close()
compress
} catch (e: Exception) {
e.printStackTrace()
false
}
}
// 获得图库路径
fun getGalleryDir(): String {
val externalStoragePublicDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
try {
externalStoragePublicDirectory.mkdirs()
} catch (e: Exception) {
}
return externalStoragePublicDirectory.absolutePath
}
// 获得MD5字符串
fun encrypt(str: String?): String {
var str = str
val str2 = ""
if (str == null) {
str = ""
}
try {
val instance = MessageDigest.getInstance("MD5")
instance.update(str.toByteArray())
val digest = instance.digest()
val stringBuffer = StringBuffer("")
for (i in digest.indices) {
var b = digest[i]
if (b < 0) {
b = (b + 256).toByte()
}
if (b < 16) {
stringBuffer.append("0")
}
stringBuffer.append(Integer.toHexString(b.toInt()))
}
return stringBuffer.toString()
} catch (e: NoSuchAlgorithmException) {
return str2
}
}
// 广播通知图库更新
fun notifyGallery(context: Context, str: String) {
context.sendBroadcast(Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE", Uri.parse("file://$str")))
}
页面MainActivity.java 新增:
iv_share_save.setOnClickListener {
val sb = StringBuilder()
sb.apply { append(getGalleryDir()).append("/").append(Test.encrypt(entryUrl)).append(".jpg") }
val picPath = sb.toString().replace("ffffff", "")
saveBitmap2file(createViewBitmap(cly_content), picPath)
notifyGallery(this, picPath)
Toast.makeText(this, "已经保存到:$picPath" +"", Toast.LENGTH_SHORT).show()
}
private fun createViewBitmap(view: View): Bitmap {
val createBitmap = Bitmap.createBitmap(view.width, view.height, Config.RGB_565)
view.draw(Canvas(createBitmap))
return createBitmap
}
最后的运行效果图如下:
这里有个小坑我纠结了许久,就是生成的md5字符串一直和掘金的不一样,后来发现加密的字符串不是文章的链接,而是:juejin.cn/post/1 哈哈。行吧,到此,掘金消息卡片的代码总算收入囊中,完整的代码,公号回复003取需。
0x7、碎碎念
偷代码只是开开玩笑,毕竟是别人的劳动结晶!尊重他人劳动成果,限于我们自己的阅历,或者没有大神带,有些功能以自己当前水平没办法写出来, 此时借鉴别人的代码,也不失为一个好的方法。而且研究别人写的代码挺有趣的,一层一层刨开,揣摩作者的意图,用到了什么技巧,怎么用到自己的项目中,等等,耗时,但获益良多。最后说一句,仅用于技术研究学习之用,请勿用于商业用途!破坏计算机信息系统罪了解下?非常鄙视那种二次打包别人APP,然后塞广告或者病毒的人。
(PS:公号回复00x返回对应资源,没别的意思,只是方便自己和群友,有些人看了我的文章,反手就问:那个东西去哪里下?资源失效了?有那个XX吗?等等这些问题,而我又要去打开文章,然后想想资源在哪,重新传一下,然后又回头把几个平台的文章改一下,好烦咯!So,丢公号去了!别吐槽我啊,关键字和官网啥的我都有给,可以自己百度!)
行吧,就说这么多,如果纰漏或建议,欢迎在评论区指出,谢谢~
参考文献:
如果本文对你有所帮助,欢迎
留言,点赞,转发
素质三连,谢谢😘~