阅读 14603

忘了他吧!我偷别人APP的代码养你

一个开发仔的日常离不开和产品经理的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

TipsVirtual 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://user-gold-cdn.xitu.io/2019/5/10/16a9fc6bbb83e12e?imageView2/0/h/110/q/110"
    private val authorNickName = "coder-pig"
    private val authorDesc = "网管@抠腚网咖"
    private val authorLevel = 1
    private val articleCoverUrl =
        "https://user-gold-cdn.xitu.io/2019/7/24/16c22e09ebcc9819?w=1918&h=1067&f=jpeg&s=601259"
    private val articleTitle = "Kotlin刨根问底——你真的了解Kotlin中的空安全吗?"
    private val articleSummary =
        "每个人的时间都是有限的,一旦做出学习某块知识的选择,意味着付出了暂时无法学习其他知识的机会成本,需要取舍。不可能等什么都学会了再去面试,学完得猴年马月,而且技术,是学不完的… 初次接触Kotlin已是三年前,在上家公司用Kotlin重构了平板的应用市场和电台APP。说来惭愧,至…"
    private val articleUrl = "https://juejin.im/entry/5d38086f6fb9a07f00531d24"

    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.im/entry/xxx 哈哈。行吧,到此,掘金消息卡片的代码总算收入囊中,完整的代码,公号回复003取需。


0x7、碎碎念

偷代码只是开开玩笑,毕竟是别人的劳动结晶!尊重他人劳动成果,限于我们自己的阅历,或者没有大神带,有些功能以自己当前水平没办法写出来, 此时借鉴别人的代码,也不失为一个好的方法。而且研究别人写的代码挺有趣的,一层一层刨开,揣摩作者的意图,用到了什么技巧,怎么用到自己的项目中,等等,耗时,但获益良多。最后说一句,仅用于技术研究学习之用,请勿用于商业用途!破坏计算机信息系统罪了解下?非常鄙视那种二次打包别人APP,然后塞广告或者病毒的人。

(PS:公号回复00x返回对应资源,没别的意思,只是方便自己和群友,有些人看了我的文章,反手就问:那个东西去哪里下?资源失效了?有那个XX吗?等等这些问题,而我又要去打开文章,然后想想资源在哪,重新传一下,然后又回头把几个平台的文章改一下,好烦咯!So,丢公号去了!别吐槽我啊,关键字和官网啥的我都有给,可以自己百度!)

行吧,就说这么多,如果纰漏或建议,欢迎在评论区指出,谢谢~


参考文献


如果本文对你有所帮助,欢迎
留言,点赞,转发
素质三连,谢谢😘~


关注下面的标签,发现更多相似文章
评论