阿里热修复最新版本

热修复技术现在已经很成熟了,至今还没有用过。虽然框架很多,但这里只介绍Sophix,原因不言而喻,对于技术来说谁的好用用谁的。Sophix亮点有一下几点

  • 使用起来配置简单,傻瓜式的接入
  • 功能也比较强大
  • 几乎兼容所有机型
  • 支持方法,资源文件,so等替换
  • 阿里云服务器支持

一,热修复框架对比

1,各大热修复框架对比图,详细对比请看Android 热修复调研报告—流行方案选择

2,Sophix的演化,阿里云官方文档

二,开始用起来

1,android studio集成方式
gradle远程仓库依赖, 打开项目找到app的build.gradle文件,添加如下配置:
添加maven仓库地址:

    repositories {maven {url "http://maven.aliyun.com/nexus/content/repositories/releases"}}

2,添加gradle版本依赖:

    compile 'com.aliyun.ams:alicloud-android-hotfix:3.2.8'

3,添加权限

    <! -- 网络权限 --><uses-permission android:name="android.permission.INTERNET" /><uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /><uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /><! -- 外部存储读权限,调试工具加载本地补丁需要 --><uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

4,在AndroidManifest.xml中间的application节点下添加如下配置:

    <meta-dataandroid:name="com.taobao.android.hotfix.IDSECRET"android:value="App ID" /><meta-dataandroid:name="com.taobao.android.hotfix.APPSECRET"android:value="App Secret" /><meta-dataandroid:name="com.taobao.android.hotfix.RSASECRET"android:value="RSA密钥" />

5,SDK接口接入

    // initialize必须放在attachBaseContext最前面,初始化代码直接写在Application类里面,切勿封装到其他类。SophixManager.getInstance().setContext(this).setAppVersion(appVersion).setAesKey(null).setEnableDebug(true).setPatchLoadStatusStub(new PatchLoadStatusListener() {@Overridepublic void onLoad(final int mode, final int code, final String info, final int handlePatchVersion) {// 补丁加载回调通知if (code == PatchStatus.CODE_LOAD_SUCCESS) {// 表明补丁加载成功} else if (code == PatchStatus.CODE_LOAD_RELAUNCH) {// 表明新补丁生效需要重启. 开发者可提示用户或者强制重启;// 建议: 用户可以监听进入后台事件, 然后调用killProcessSafely自杀,以此加快应用补丁,详见1.3.2.3} else {// 其它错误信息, 查看PatchStatus类说明}}}).initialize();// queryAndLoadNewPatch不可放在attachBaseContext 中,否则无网络权限,建议放在后面任意时刻,如onCreate中SophixManager.getInstance().queryAndLoadNewPatch();

6,稳健接入

// 此处SophixEntry应指定真正的Application,并且保证RealApplicationStub类名不被混淆 @Keep
@SophixEntry(MyRealApplication.class)
static class RealApplicationStub {}

三,阿里云创建应用

1,登录阿里云官网

2,创建应用

①,登录后进入管理控制台

②,添加产品,之后添加应用

③,应用添加完成

④,把appKey,AppSecret,RSA对应的值写入到AndroidManifest.xml中间的application节点下meta-data中,这里需要注意,如果你遇到这个问题 这就需要通过SDK接口接入的方式写入appKey,AppSecret,RSA。

通过setSecretMetaData(“App ID”,“App Secret”,“RSA密钥”)写入对应的值即可

四,开始编码

1,生成补丁
修改项目xml中TextView内容,修改前打个包old.apk,修改后打个包new.apk。测试包不用签名,SDK初始化方法设置为setEnableDebug(true),然后下载补丁生成工具 补丁下载地址下载完成后运行SophixPatchTool.exe

2,本地测试方式

①,补丁生成后是一个jar,把这个jar拷贝到自己手机的skcard中,下载官方测试应用 测试程序

②,安卓6.0手机注意,需要动态添加权限。

添加动态权限

 /*** 如果本地补丁放在了外部存储卡中, 6.0以上需要申请读外部存储卡权限才能够使用. 应用内部存储则不受影响*/private void requestExternalStoragePermission() {if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)!= PackageManager.PERMISSION_GRANTED) {ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE},REQUEST_EXTERNAL_STORAGE_PERMISSION);}}

4,云端测试方式

用测试应用扫描二维码,扫描后会下载补丁,会有相应的日志

注意:如果正式发版,需要SDK初始化方法设置为setEnableDebug(false),生成的补丁需要签名,用自己应用的签名

5,异常定位

code异常定位,常见的一些code值。

      //兼容老版本的code说明int CODE_LOAD_SUCCESS = 1;//加载阶段, 成功int CODE_ERR_INBLACKLIST = 4;//加载阶段, 失败设备不支持int CODE_REQ_NOUPDATE = 6;//查询阶段, 没有发布新补丁int CODE_REQ_NOTNEWEST = 7;//查询阶段, 补丁不是最新的 int CODE_DOWNLOAD_SUCCESS = 9;//查询阶段, 补丁下载成功int CODE_DOWNLOAD_BROKEN = 10;//查询阶段, 补丁文件损坏下载失败int CODE_UNZIP_FAIL = 11;//查询阶段, 补丁解密失败int CODE_LOAD_RELAUNCH = 12;//预加载阶段, 需要重启int CODE_REQ_APPIDERR = 15;//查询阶段, appid异常int CODE_REQ_SIGNERR = 16;//查询阶段, 签名异常int CODE_REQ_UNAVAIABLE = 17;//查询阶段, 系统无效int CODE_REQ_SYSTEMERR = 22;//查询阶段, 系统异常int CODE_REQ_CLEARPATCH = 18;//查询阶段, 一键清除补丁int CODE_PATCH_INVAILD = 20;//加载阶段, 补丁格式非法//查询阶段的code说明int CODE_QUERY_UNDEFINED = 31;//未定义异常int CODE_QUERY_CONNECT = 32;//连接异常int CODE_QUERY_STREAM = 33;//流异常int CODE_QUERY_EMPTY = 34;//请求空异常int CODE_QUERY_BROKEN = 35;//请求完整性校验失败异常int CODE_QUERY_PARSE = 36;//请求解析异常int CODE_QUERY_LACK = 37;//请求缺少必要参数异常//预加载阶段的code说明int CODE_PRELOAD_SUCCESS = 100;//预加载成功int CODE_PRELOAD_UNDEFINED = 101;//未定义异常int CODE_PRELOAD_HANDLE_DEX = 102;//dex加载异常int CODE_PRELOAD_NOT_ZIP_FORMAT = 103;//基线dex非zip格式异常int CODE_PRELOAD_REMOVE_BASEDEX = 105;//基线dex处理异常//加载阶段的code说明 分三部分dex加载, resource加载, lib加载//dex加载int CODE_LOAD_UNDEFINED = 71;//未定义异常int CODE_LOAD_AES_DECRYPT = 72;//aes对称解密异常int CODE_LOAD_MFITEM = 73;//补丁SOPHIX.MF文件解析异常int CODE_LOAD_COPY_FILE = 74;//补丁拷贝异常int CODE_LOAD_SIGNATURE = 75;//补丁签名校验异常int CODE_LOAD_SOPHIX_VERSION = 76;//补丁和补丁工具版本不一致异常int CODE_LOAD_NOT_ZIP_FORMAT = 77;//补丁zip解析异常int CODE_LOAD_DELETE_OPT = 80;//删除无效odex文件异常int CODE_LOAD_HANDLE_DEX = 81;//加载dex异常// 反射调用异常int CODE_LOAD_FIND_CLASS = 82;int CODE_LOAD_FIND_CONSTRUCTOR = 83;int CODE_LOAD_FIND_METHOD = 84;int CODE_LOAD_FIND_FIELD = 85;int CODE_LOAD_ILLEGAL_ACCESS = 86;//resource加载public static final int CODE_LOAD_RES_ADDASSERTPATH = 123;//新增资源补丁包异常//lib加载int CODE_LOAD_LIB_UNDEFINED = 131;//未定义异常int CODE_LOAD_LIB_CPUABIS = 132;//获取primaryCpuAbis异常int CODE_LOAD_LIB_JSON = 133;//json格式异常int CODE_LOAD_LIB_LOST = 134;//lib库不完整异常int CODE_LOAD_LIB_UNZIP = 135;//解压异常int CODE_LOAD_LIB_INJECT = 136;//注入异常

6,SDK接口说明

①,initialize方法

initialize(): <必选>该方法主要做些必要的初始化工作以及如果本地有补丁的话会加载补丁, 但不会自动请求补丁。因此需要自行调用queryAndLoadNewPatch方法拉取补丁。这个方法调用需要尽可能的早, 必须在Application的attachBaseContext方法的最前面调用(在super.attachBaseContext之后,如果有Multidex,也需要在Multidex.install之后), initialize()方法调用之前你需要先调用如下几个方法进行一些必要的参数设置, 方法调用说明如下:setContext(application): <必选> 传入入口Application即可setAppVersion(appVersion): <必选> 应用的版本号setSecretMetaData(idSecret, appSecret, rsaSecret): <可选,推荐使用> 三个Secret分别对应AndroidManifest里面的三个,可以不在AndroidManifest设置而是用此函数来设置Secret。放到代码里面进行设置可以自定义混淆代码,更加安全,此函数的设置会覆盖AndroidManifest里面的设置,如果对应的值设为null,默认会在使用AndroidManifest里面的。setEnableDebug(isEnabled): <可选> isEnabled默认为false, 是否调试模式, 调试模式下会输出日志以及不进行补丁签名校验. 线下调试此参数可以设置为true, 查看日志过滤TAG:Sophix, 同时强制不对补丁进行签名校验, 所有就算补丁未签名或者签名失败也发现可以加载成功. 但是正式发布该参数必须为false, false会对补丁做签名校验, 否则就可能存在安全漏洞风险setAesKey(aesKey): <可选> 用户自定义aes秘钥, 会对补丁包采用对称加密。这个参数值必须是16位数字或字母的组合,是和补丁工具设置里面AES Key保持完全一致, 补丁才能正确被解密进而加载。此时平台无感知这个秘钥, 所以不用担心阿里云移动平台会利用你们的补丁做一些非法的事情。setPatchLoadStatusStub(new PatchLoadStatusListener()): <可选> 设置patch加载状态监听器, 该方法参数需要实现PatchLoadStatusListener接口, 接口说明见1.3.2.2说明setUnsupportedModel(modelName, sdkVersionInt):<可选> 把不支持的设备加入黑名单,加入后不会进行热修复。modelName为该机型上Build.MODEL的值,这个值也可以通过adb shell getprop | grep ro.product.model取得。sdkVersionInt就是该机型的Android版本,也就是Build.VERSION.SDK_INT,若设为0,则对应该机型所有安卓版本。目前控制台也可以直接设置机型黑名单,更加灵活。

②,queryAndLoadNewPatch方法

该方法主要用于查询服务器是否有新的可用补丁. SDK内部限制连续两次queryAndLoadNewPatch()方法调用不能短于3s, 否则的话就会报code:19的错误码. 如果查询到可用的话, 首先下载补丁到本地, 然后

应用原本没有补丁, 那么如果当前应用的补丁是热补丁, 那么会立刻加载(不管是冷补丁还是热补丁). 如果当前应用的补丁是冷补丁, 那么需要重启生效.应用已经存在一个补丁, 请求发现有新补丁后,本次不受影响。并且在下次启动时补丁文件删除, 下载并预加载新补丁。在下下次启动时应用新补丁。补丁在后台发布之后, 并不会主动下行推送到客户端, 需要手动调用queryAndLoadNewPatch方法查询后台补丁是否可用.
只会下载补丁版本号比当前应用存在的补丁版本号高的补丁, 比如当前应用已经下载了补丁版本号为5的补丁, 那么只有后台发布的补丁版本号>5才会重新下载.

同时1.4.0以上版本服务后台上线了“一键清除”补丁的功能, 所以如果后台点击了“一键清除”那么这个方法将会返回code:18的状态码. 此时本地补丁将会被强制清除, 同时不清除本地补丁版本号

③,killProcessSafely方法

可以在PatchLoadStatusListener监听到CODE_LOAD_RELAUNCH后在合适的时机,调用此方法杀死进程。注意,不可以直接Process.killProcess(Process.myPid())来杀进程,这样会扰乱Sophix的内部状态。因此如果需要杀死进程,建议使用这个方法,它在内部做一些适当处理后才杀死本进程。

④,cleanPatches()方法

清空本地补丁,并且不再拉取被清空的版本的补丁。正常情况下不需要开发者自己调用,因为Sophix内部会判断对补丁引发崩溃的情况进行自动清空。

⑤, PatchLoadStatusListener接口,

mode: 无实际意义, 为了兼容老版本, 默认始终为0
code: 补丁加载状态码, 详情查看PatchStatus类说明
info: 补丁加载详细说明
handlePatchVersion: 当前处理的补丁版本号, 0:无 -1:本地补丁 其它:后台补丁

五,热修复原理

热修复方案
市面上流行的热修复框架主要有三个方案,类加载方案,底层替换方案和Instant Run方案

1,类加载方案
先了解一下Android的ClassLoader

  • ClassLoader是一个抽象类,其中定义了ClassLoader的主要功能。BootClassLoader是它的内部类。
  • BootClassLoader:启动了加载器,和Java虚拟机不同,BootClassLoader是由Java代码实现,而不是C++实现。
  • BaseDexClassLoader:用于加载dex文件,PathClassLoader和DexClassLoader是它的两个实现类。
  • DexClassLoader:支持加载APK、dex、jar,也可以从SD卡加载。
  • PathClassLoader:该加载器将optomizedDirectory设置为null,默认路径为/data/dalvik-cache目录,即加载已经安装的应用

Java Class的加载源码如下:

   protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException{synchronized (getClassLoadingLock(name)) {// First, check if the class has already been loadedClass c = findLoadedClass(name);//检查是否已经加载if (c == null) {long t0 = System.nanoTime();try {if (parent != null) {c = parent.loadClass(name, false);//委派给双亲} else {c = findBootstrapClassOrNull(name);//委派给启动类}} catch (ClassNotFoundException e) {// ClassNotFoundException thrown if class not found// from the non-null parent class loader}if (c == null) {// If still not found, then invoke findClass in order// to find the class.long t1 = System.nanoTime();c = findClass(name);// this is the defining class loader; record the statssun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);sun.misc.PerfCounter.getFindClasses().increment();}}if (resolve) {resolveClass(c);}return c;}}

可以看出使用双亲委派机制,即先到父加载器进行加载,加载不到才使用自己进行加载。

类加载方案也是基于dex分包方案,由于Android项目有 65535方法限制,从而产生了dex分包方案。Dex分包是在打包时将代码分成多个Dex,将应用启动时必须用到的类和这些类的直接引用类放到主Dex中,其他代码放到次Dex中。当应用启动时先加载主Dex,等到应用启动后再动态的加载次Dex,从而缓解了主Dex的65536限制。

ClassLoader的加载过程中,会调用DexPathList中的findClass的方法代码如下:

   public Class<?> findClass(String name, List<Throwable> suppressed) {for (Element element : dexElements) {Class<?> clazz = element.findClass(name, definingContext, suppressed);if (clazz != null) {return clazz;}}if (dexElementsSuppressedExceptions != null) {suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));}return null;}

Element内部封装了DexFile用于加载dex文件,因此每个dex文件对应一个Element。这就是关键地方,我们可以将有bug的类test.class进行修改,然后将test.class打包dex的补丁包test.jar,放在Element数组dexElements的第一个元素,这样首先找到test.dex中的test.class去替换之前存在bug的test.class,排在数组后面的dex文件中的存在bug的test.class根据ClassLoader的双亲委托模式就不会被加载。

类加载方案需要重启App才能生效,不能即时生效。因为在App启动之后所有类已经加载完成,在Android上是无法对类进行卸载。如果不重启,类还在虚拟机中。

Sophix采用全量合成dex技术,直接利用Android原先的类查找和合成技术,快速合成新的全量dex。在虚拟机查找类的时候,会优先找到classes.dex中的类,然后才是classes2.dex,classes3.dex,也就是把补丁包中的类命名为classes.dex。

2,底层替换方案
底层替换不同的地方是可以及时生效,可以直接在Native层直接修改原类,底层替换方案通过在运行时利用hook操作native指针实现“热”的特性,底层替换所操作的指针,实际上是ArtMethod,在类被加载,类中的每个方法都会有对应的ArtMethod,它记录了方法包括所属类和内存地址信息。

由于不同的厂商对ArtMethod结构进行了修改,Sophix采用了对旧ArtMethod进行完整替换。因此Sophix采用类加载和底层替换相结合的方案。

3,Instant Run方案

Instant Run是基于多ClassLoader的,每一个patch都有一个ClassLoader,这就意味着如果你想更新patch,它都会创建一个ClassLoader,而在java中不同ClassLoader创建的类被认为是不同的,所以会重新加载新的patch中的补丁类。

Instant Run原理:

  • 首先构造一个新的AssetManager,并通过反射调用addAssetPath方法,把这个完整的新资源包加入到AssetManager中,这样就获得了一个含有所有新资源的AssetManager。
  • 找到所有之前引用到原有AssetManager的地方,通过反射将引用处替换成AssetManager。

Sophix原理:

  • 构造一个package id为0x66的资源包,该包只包含修改了的资源项,采用的替换方式是直接在原有的AssetManager对象上进行析构和重构。
  • 由于补丁包的package id 为0x66,不与目前已经加载的0x7f冲突,因此直接加入到已有的AssetManager中就可以使用了。

4,so修复
so库的修复本质上是对native方法修复和替换。

加载so的两个接口:

  • System.loadLibrary(String libname):加载apk中的lib目录
  • System.load(String pathName):加载磁盘中的so

两种加载方式,实际上最后都调用nativeLoad这个native方法加载so库,这个方法参数filename:so库在磁盘中的完整路径名

Sophix是在启动期间反射注入patch中的so库,把补丁so库的路径插入到nativeLibraryDirectories数组的最前面,从而达到替换的目的。

六,Sophix使用总结

1,不支持四大组件
Sophix是不支持四大组件的修复,不支持四大组件(AndroidManifest中内容不能修改,组件内的代码可以修改)。如果要修复四大组件,必须在AndroidManifest里面预先插入代理组件,这样对app侵入性比较强。

2,即时生效的限制
即时生效只能支持方法的替换,而对于补丁类里面存在方法增加和减少,以及成员字段的增加和减少都不支持。

3,覆盖patch需要重启APP生效么?

  • 同一版本的Android端第一次被打补丁时,不用重启直接加载(支持热启动)。
  • 同一版本的Android端在被第二次及更多次打补丁时,需要重启(只能冷启动)。

4,不是所有资源都能修复

AndroidManifest.xml里面的变动无法修复,因为AndroidManifest.xml是由系统在安装app时解析,因此在运行时app无法修改它的逻辑的。

AndroidManifest.xml里面的资源不支持,通知栏图标、启动图标资源以及RemoteViews也不支持修复。原因是这类资源是由系统负责展示的,而系统只会在安装包中找资源,不会找到补丁包。

自测发现,资源中的音视频文件也无法实现修复,具体原因目前未知。

附加:如果是系统内置应用,要想使用Sophix必须要把Sophix生成的so文件拷贝到系统system/lib下。so查找方式,生成的apk修改后缀为zip,然后解压会有对应的so文件。有更多问题可以加入阿里钉钉群11711603

Android热修复Sophix详解相关推荐

  1. 阿里云热修复sophix详解

    现在网上有几种常用的app热修复技术,个人感觉阿里云热修复操作比较简单,主要几个步骤,创建app---下载sdk---集成(AS和eclipse)---生成补丁---发布补丁(可以本地调试).下面详细 ...

  2. Android热修复原理(HotFix)初涉

    写在最前的话,一直听说热修复,不错,最近修复风靡,不明白原理都不行,明白原理了不会用也不行,故打算拿出一些时间去深入了解一番 翻阅众多资料 在此之前先感谢前人的资料提供, 好了 大家和我一起学习吧: ...

  3. Android热修复技术原理详解(最新最全版本)

    本文框架 什么是热修复? 热修复框架分类 技术原理及特点 Tinker框架解析 各框架对比图 总结   通过阅读本文,你会对热修复技术有更深的认知,本文会列出各类框架的优缺点以及技术原理,文章末尾简单 ...

  4. [读书笔记] 深入探索Android热修复技术原理 (手淘技术团队)

    热修复技术介绍 探索之路 最开始,手淘是基于Xposed进行了改进,产生了针对Android Dalvik虚拟机运行时的Java Method Hook技术--Dexposed. 但该方案对于底层Da ...

  5. android热补丁作用,Android热修复之 - 阿里开源的热补丁

    这里就有一个概念那就AndFix.apatch补丁用来修复方法,接下来我们看看到底是怎么实现的. 1.2 生成apatch包 假如我们收到了用户上传的崩溃信息,我们改完需要修复的Bug,这个时候就会有 ...

  6. 类加载机制实现Android热修复

    本文通过类加载机制实现Android热修复,Demo实现的功能:检测服务器是否存在补丁,存在即下载补丁,安装补丁,重启APP生效.支持多个补丁包修复:如果已经下载了多个补丁包,重启app对补丁包进行排 ...

  7. Android热修复之 阿里开源的热补丁

    1.概述   上一期讲到Android热修复之 - 收集崩溃信息上传至服务器,我们获取到用户手中上线的崩溃信息上传到服务器后该怎么办?如果直接发布版本要用户去下载肯定不乐意.这一期我们来看一下怎么去打 ...

  8. 深入解析阿里Android热修复技术原理

    前言:本文框架 什么是热修复? 热修复框架分类 技术原理及特点 Tinker框架解析 各框架对比图 总结 通过阅读本文,你会对热修复技术有更深的认知,本文会列出各类框架的优缺点以及技术原理,文章末尾简 ...

  9. android热修复原理底层替换,Android 热修复 - 各框架原理学习及对比

    写在开头 从15年开始各技术大佬们开始研究热修复技术,并陆续开源了许多的热修复框架.如 Jasonross 的 Nuwa,美团的 Robust,阿里的 Andfix,腾讯的 Tinker 等等...均 ...

  10. 【Android 修炼手册】常用技术篇 -- Android 热修复解析

    这是[Android 修炼手册]第 8 篇文章,如果还没有看过前面系列文章,欢迎点击 这里 查看- 预备知识 了解 android 基本开发 了解 ClassLoader 相关知识 看完本文可以达到什 ...

最新文章

  1. Microbiome:利用Nanopore高通量测序技术解析污水处理体系可移动抗性基因组(一作解读)
  2. 程序员进阶之算法练习:LeetCode专场
  3. SAP COR2下达工单,报错 System status APNG is active 之对策
  4. 通信系统之信道(二)
  5. pl/sql显示乱码
  6. 【转】javascript中的LHS与RHS
  7. Java提高篇 —— Java浅拷贝和深拷贝
  8. WordPress开发暗黑系列流量主收益高清壁纸小程序-可二开-无授权
  9. VUE使用过滤器来格式化当前时间
  10. 计算机辅助制图cad论文,cad论文模板
  11. 物联网技术-RFID
  12. android中实现一键加QQ群功能
  13. 购房指南—新房交房注意事项细节有哪些
  14. 微信小程序-知晓云等云产品导出excel
  15. switchport nonegotiate
  16. android animation
  17. python数据分析实战:DCM模型设计及实现(以波音公司用户选择为例)
  18. 不定积分知识结构图_高等数学各章知识结构(有高度、深度)
  19. 手机吃鸡语音服务器异常错误,绝地求生游戏报错解决方法汇总
  20. Java集合(List、Set)

热门文章

  1. html中div中文字如何上下居中,div中文字各种垂直居中的方法
  2. 强化学习笔记 Ornstein-Uhlenbeck 噪声和DDPG
  3. wps文字表格制作拼音田字格模板_最新用WPS表格快速制作拼音田字格的方法
  4. iOS经典讲解之Socket使用教程
  5. 自媒体短视频怎么制作?视频制作大神分享的超全教程,新手也能轻松上手!
  6. Pycharm破解(学习python的day01)
  7. 图解PLC与变频器通讯接线
  8. nc交换平台翻译器翻译仓库问题以及解决方法
  9. 太极发送卡片软件_xml卡片消息制作软件下载-qq xml卡片消息生成器最新版0.8.10.209 免费版-东坡下载...
  10. 用python画图的好处_用Python绘图,感受编程之美