Skip to content
kissonchan edited this page Aug 1, 2019 · 14 revisions

Qigsaw

Qigsaw

介绍

Qigsaw 是爱奇艺提供的一套基于 Android App Bundle 的动态化方案,无需谷歌 Play Service 即可在国内体验 Android App Bundle开发工具。它支持动态下发插件 APK,让应用能够在不重新安装的情况下实现动态安装插件。

关于AAB

Android App Bundles(以下简称AAB)是2018年Google I/O大会带来的一款全新动态化框架,与Instant App不同,AAB是借助Split Apk完成动态加载。AAB的技术特点如下:

  1. 不同于传统的App以整个Apk为单位,一个App被分割成了一个base Apk和多个split Apk。

  2. Apk在安装后,可以按需请求下发或者更新模块Apk。

  3. split Apk可以根据配置进行更细的划分,并根据当前运行的设备特征来请求特定的Apk。

  4. 请求与安装过程必须通过Google Play商店进行,Google进一步统一标准和巩固自身生态。

  5. split Apk功能只在Android 5.0(API 21)及以上机型使用,针对Android 4.4及以下机型Google Play商店仍会下发完整的Apk,以此来向下兼容。

    以下是AAB运行时动态加载Apk与传统方式下发Apk的对比示意图:

android app bundle

AAB是一个动态化框架,它是利用Android Framework提供的split apks功能完成。所有安装split apk工作均是通过IPC交由google play完成。

为什么选择Qigsaw

由于国内无法使用Google Play商店,开发者即使将app打包成AAB格式也无法将其拆解下发到客户端。所以Qigsaw利用AAB开发套件,“山寨”Play Core Library公开接口实现,支持AAB所有功能特性,给开发者带来原生般的极速开发体验。Qigsaw的核心优势如下:

  1. 使用AAB原生的开发套件,带来极速的开发体验。

  2. 支持AAB所有功能特性,"山寨"Play Core Library公开接口实现,开发者可直接阅读Google官方文档进行开发。

  3. 任何进程均可动态加载插件,支持Android四大组件动态加载。

  4. 如果您的应用有出海需求,可无缝切换至Android App Bundle方案,无需进行二次开发。

  5. 仅一处Hook(Android9.0+无需Hook),少量私有API访问,保证框架稳定性。

国内 Android 插件化方案早已百花齐放,比如 DroidPlugin、Replugin、VirtualAPK 等。但这些插件化方案开发、维护成本都较高。

plugin_work_mode

插件化方案一般是基线工程和插件工程独立开发,基线工程提供基础 SDK 供插件工程编译使用,如此可以避免类重复问题。这种开发模式对于大公司来说可以接受,因为人力充足,业务线丰富。但对于对于高速发展的创业公司来说,反而会成为一种负担,因为需要额外开发人力服务于插件工程。

另外国内插件化方案一般将插件 APK 运行插件:plugin进程。如此您提供IPC方案,用于跨进程通信。

plugin_ipc

Qigsaw 完全利用 Android App Bundle 开发套件,插件作为一个模块和基线工程一起编译。

qigsaw_project_structure

Android App Bundle 提供新的Gradle-Plugin com.android.dynamic-feature, 用于编译插件。在整个工程中,Dynamic-Feature 模块可以使用其他任何模块,包括 Library、 Application、甚至其他Dynamic-Feature模块。您将以上帝视角俯视整个App工程,插件不再是围墙之外的孤儿。

开发阶段,你将享受 Android App Bundle 极速开发体验。

qigsaw_debug_mode

当您的项目编译完成后,Android Studio通过adb install-multiple命令将 base APK 和插件 APK 安装至手机中。如果您的开发手机系统版本低于5.0,则会依据当前手机设备组装成一个完整apk文件安装至该手机。

vivo手机不支持多 APK 安装功能,因此开发过程中请选取其他手机。或者使用Qigsaw打包插件提供的qigsawAssemble${variantName}命令

发布阶段,你将享受 Qigsaw 提供的一条龙服务,插件开发者不必关心插件 APK 的上传分发。

qigsaw_release_mode

Qigsaw打包插件支持内置插件,所有内置插件都会被拷贝至base apk的assets目录。对于非内置插件,Qigsaw打包插件会将其上传至CDN服务器(需要接入方实现上传CDN接口),解决业务方后顾之忧。

Qigsaw 目前在爱奇艺内部已有五个业务团队在使用,包括爱奇艺App和一些创新项目。国内的插件化方案,对于小团队来说开发维护成本很大,不仅需要维护Android工程相关SDK,还需要开发插件发布平台。Qigsaw 很好的解决这些问题,在爱奇艺一些创新项目中,Qigsaw发挥很大作用,为他们减少约20%~30%包体积。

Qigsaw 打包插件做的事情很少,因此当Android Gradle Plugin 或者 Gradle 升级时,Qigsaw 适配起来非常简单。然而国内插件化方案打包插件升级进度很慢,有些根本不再适配,严重阻碍开发效率。

快速开始

开发环境

在 Android Studio 3.2 或更高版本、Unity 2018.3 与 2017.4.17,以及 Cocos Creator 2.0.9 或更高版本中受支持。Qigsaw完全利用AAB的开发工具实现,开发环境与AAB要求一致。

Android Studio版本是3.2+,那么Gradle版本最好配置4.10.1(低于5.0版本),否则可能会引起一些编译问题。

接入步骤

Qigsaw提供了与AAB相似的接入方式,开发人员只需在gradle中进行配置引入。

在你的项目中新建名为qigsaw_feature的dynamic feature模块,File->New->New Module->Dynamic Feature Module,默认填入相关配置信息完成插件创建。

dynamic_feature_module

app/build.gradle中会自动生成dynamicFeatures数组,记录所有插件名称。

android {
    compileSdkVersion 28
    buildToolsVersion "29.0.0"
    defaultConfig {
        applicationId "com.example.qigsaw_sample"
        minSdkVersion 14
        targetSdkVersion 28
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    //所有插件会被记录在数组dynamicFeatures中
    dynamicFeatures = [":qigsaw_feature"]
}

在项目根目录下的build.gradle文件中增加qigsaw-gradle-plugin作为依赖。

buildscript {
    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.3.2'
        classpath 'com.iqiyi.android.qigsaw:gradle-plugin:1.1.4'
    }
}

app/build.gradle应用com.iqiyi.qigsaw.application插件,并引入qigsaw-core-library依赖。

apply plugin: 'com.android.application'
apply plugin: 'com.iqiyi.qigsaw.application'

...

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    ......
    implementation 'com.iqiyi.android.qigsaw:splitcore:1.1.4'
    ......
}

app/build.gradle中配置qigsawSplit拓展选项。

qigsawSplit {

    /**
     * 可选项,默认为null
     * 如果插件需要热更新,必须指定old apk文件路径。
     */
    oldApk = projectDir.getPath() + "/qigsaw/app.apk"
    
    /**
     * 可选项, 默认为'null'
     * 限制插件运行的进程,如果配置插件名,插件仅运行在其AndroidManifest文件声明的所有进程(android:process标签)。否则,所有进程均可运行该插件。
     */
    restrictWorkProcessesForSplits = ['qigsaw_feature']

    /**
     * 可选项,默认为1.0.0 
     * 插件信息 JSON 文件版本号,当插件更新时,必须修改其值。
     */
    splitInfoVersion '1.0.0'

    /**
     * 可选项,默认为null
     * 如果插件需要热更新,需要指定old apk生成的mapping文件。
     */
    applyMapping projectDir.getPath() + '/qigsaw/mapping.txt'
    
    /**
     * 可选项,默认为false
     * 控制app编译期间是否上传插件至CDN。推荐Debug阶段为false,Release阶段为true。
     */
    releaseSplitApk false
}

插件是否上传至您的CDN服务器,由两个因素解决。

一是 releaseSplitApk 必须设置为 true。

二是插件 AndroidManifest 文件onDemand 标签值设置为 true。

dynamicFeature/build.gradle中应用com.iqiyi.qigsaw.dynamicfeature插件。

apply plugin: 'com.android.dynamic-feature'
apply plugin: 'com.iqiyi.qigsaw.dynamicfeature'
android {
    compileSdkVersion 28

    defaultConfig {
        minSdkVersion 14
        targetSdkVersion 28
        versionCode 1
        versionName "1.0"
    }
}
...

接口实现

插件上传器

Qigsaw在app编译过程中将插件上传至您的CDN服务器,Qigsaw-Gradle-Plugin提供SplitApkUploader接口供开发者实现插件上传逻辑。

interface SplitApkUploader {
    
    /**
     * 建议插件上传路径后缀拼接带上插件文件md5值,仅仅通过插件名无法唯一确定插件上传地址。
     */
    String uploadSync(Project appProject, File splitApk, String splitName) throws SplitApkUploadException

}

具体实现可参考qigsaw-android-samplebuildSrc相关代码逻辑。

插件下载器

Qigsaw-Core-Library提供Downloader接口供开发者实现插件下载逻辑。考虑到app自身一般都有下载器模块,因此Qigsaw仅给出接口定义。

public interface Downloader {

    /**
     * 组任务立即下载,拥有最高下载优先级。
     */
    void startDownload(int sessionId, List<DownloadRequest> requests, DownloadCallback callback);

    /**
     * 延时下载,即组任务设置为较低优先级再开始下载。
     */
    void deferredDownload(int sessionId, List<DownloadRequest> requests, DownloadCallback callback, boolean usingMobileDataPermitted);

    /**
     * 根据sessionId取消对应组任务下载,注意必须是同步执行。
     */
    boolean cancelDownloadSync(int sessionId);

    /**
     * 蜂窝网络情况下,允许插件下载总大小的阈值,Android App Bundle阈值设定是10M。
     */
    long getDownloadSizeThresholdWhenUsingMobileData();

    /**
     * 设置延时组任务是否仅仅在wifi网络下载。
     */
    boolean isDeferredDownloadOnlyWhenUsingWifiData();

}

自定义Downloader需满足以下功能:

  1. sessionId唯一标识组任务。

  2. 支持组任务下载,取消组任务。

  3. 所有文件下载完成算作一次任务成功,下载过程中能返回所有已下载文件大小。

  4. 根据sessionId,能查询组任务下载状态。

  5. 能够设置组任务下载优先级,用于立即或延时安装插件。

  6. 如果文件已经下载,则不再下载。

qigsaw-android-sample提供基于英语流利说FileDownloader实现的组任务下载器downloader,开发者可参考该示例完成自定义Downloader实现。

Qigsaw初始化

自定义Application,通过SplitConfiguration可对Qigsaw进行各项初始化配置等。具体操作可查看qigsaw-android-sample->com.iqiyi.qigsaw.sample.QigsawApplication

public class QigsawApplication extends Application {
	//qigsaw将只工作在主进程和名为qigsaw的进程
    //null或"",代表主进程
    private static final String[] workProcesses = {"", ":qigsaw"};

    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        MultiDex.install(base);
        SplitConfiguration configuration = SplitConfiguration.newBuilder()
                .workProcesses(workProcesses)
                .logger(new SampleLogger())
                //当app工程AndroidManifest文件声明的包名与其build.gradle文件声明的applicationId不一致时,需要设置manifest生命的包名。
            	   .manifestPackageName(base.getPackageName())
            	   //插件加载状态回调通知。
                .loadReporter(new SampleSplitLoadReporter(this))
            	   //插件安装状态回调通知。
                .installReporter(new SampleSplitInstallReporter(this))
                //插件更新状态回调通知。
            	   .updateReporter(new SampleSplitUpdateReporter(this))
            	   //是否在Application的onCreate阶段加载所有已安装插件,可能会带来一定性能损耗。
                 .loadInstalledSplitsOnApplicationCreate(true)
                 //自定义用户确认对话框,当插件下载总大小超过蜂窝网络下允许下载的阈值
	             .obtainUserConfirmationDialogClass(SampleObtainUserConfirmationDialog.class)
                .build();
        Qigsaw.install(this,
                new SampleDownloader(),
                configuration);
    }

    @Override
    public void onCreate() {
        super.onCreate();
        //将Application#onCreate事件通知给qigsaw。
        Qigsaw.onApplicationCreated();
    }

    @Override
    public Resources getResources() {
        //将Application#onResource事件通知给qigsaw。
        Qigsaw.onApplicationGetResources(super.getResources());
        return super.getResources();
    }
}

自定义用户确认框

Android App Bundle 中有规定当使用蜂窝网络下载插件时,插件总大小超过 10M 需要用户确认是否继续下载。Qigsaw 同样采用该策略,不过相比 Android App Bundle 一刀切的方案,Qigsaw 提供更灵活接入方式。

  1. 开发者可以设置蜂窝网络下允许插件下载的总大小阈值。
  2. Qigsaw提供默认用户确认框,同时支持接入方自定义弹框样式。

默认弹框样式如下。

custom_ObtainUserConfirmationDialog

如需自定义弹框样式,只需继承ObtainUserConfirmationDialog实现弹框样式即可,您可以参考SampleObtainUserConfirmationDialog

public final class SampleObtainUserConfirmationDialog extends ObtainUserConfirmationDialog {

    private boolean fromUserClick;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        //调用checkInternParametersIllegal检查传入参数是否非法
        if (checkInternParametersIllegal()) {
            finish();
            return;
        }
        //getModuleNames获取当前下载的所有插件
        SplitLog.d(TAG, "Downloading splits %s need user to confirm." + getModuleNames().toString());
        setContentView(R.layout.activity_sample_obtain_user_confirmation);
        getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
        getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
        setFinishOnTouchOutside(false);
        TextView descText = findViewById(R.id.sample_user_conformation_tv);
        DecimalFormat df = new DecimalFormat("#.00");
        //getRealTotalBytesNeedToDownload获取当前下载插件总大小
        double convert = getRealTotalBytesNeedToDownload() / (1024f * 1024f);
        descText.setText(String.format(getString(R.string.sample_prompt_desc), df.format(convert)));
        findViewById(R.id.sample_user_confirm).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                if (!fromUserClick) {
                    fromUserClick = true;
                    //用户确认时,必须调用onUserConfirm方法
                    onUserConfirm();
                }
            }
        });
        findViewById(R.id.sample_user_cancel).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                if (!fromUserClick) {
                    fromUserClick = true;
                    //用户取消时,必须调用onUserCancel方法
                    onUserCancel();
                }
            }
        });
    }
}

插件开发

打包指令

完成qigsaw相关配置就可在Gradle侧边栏中(projectName->Tasks->qigsaw)看到qigsaw提供的打包命令。

qigsaw_package_command

通过qigsawAssembleDebugqigsawAssembleRelease命令打包会生成文件名前缀为qigsaw的JSON文件。打包生成的Apk目录结构如下图。

qigsaw_apk_format

如果插件内置,则插件将会以dynamicfeatureName.zip文件命名形式内置在base Apk的assets目录下。

插件信息文件以qigsaw_appVersionName_splitInfoVersion.json文件命名形式内置base Apk的assets目录下,该文件记录插件名、下载地址,插件大小等信息。

{
  "qigsawId": "1.0.0_813d13d",
  "appVersionName": "1.0.0",//app版本号
  "splits": [
    {
      "splitName": "java", //插件名
      "url": "assets://java.zip", //插件下载地址,内置插件以"assets://"开头。
      "builtIn": true, //是否为内置插件
      "size": 11897, //插件apk文件大小
      "version": "1.1@1", //插件版本号
      "md5": "8c9111d88509fd5767f8eb8b6520c790", //插件apk文件md5值
      "workProcesses": [ //插件工作进程,读取的是插件AndroidManifest文件中"android:process"的指
        ":qigsaw",
        ""
      ],
      "minSdkVersion": 14, //最低支持的sdk版本,可大于base app中声明的最低版本
      "dexNumber": 2 //插件apk中dex的数目
    }
    {
      "splitName": "native",
      "url": "assets://native.zip",
      "builtIn": true,
      "size": 17808,
      "version": "1.0@1",
      "md5": "989f02a21e97e7881b26e21d4e384d48",
      "minSdkVersion": 14,
      "dexNumber": 2,
      "libInfo": {
        "abi": "armeabi-v7a",//插件so支持的abi
        "libs": [
          {
            "name": "libhello-jni.so",//插件so文件名
            "md5": "5597a187d58e395e746e65c8ff282446",//插件so文件md5值
            "size": 13948//插件so文件大小
          }
        ]
      }
    }
  ]
}

安装插件

开发过程中,提供两种方式安装插件:

  • 一种是,点击 Android Studio 的 Run 按钮,此方式是借助 Android App Bundle 开发,base Apk 和 split Apks 将会一起被安装至设备上(如果设备系统版本大于等于5.0)。

  • 另一种是,通过 qigsawAssembleDebug ,将打包生成的 app.apk (插件内置在base Apk的assets目录下)通过 abd install [apk-path] 安装至设备即可。

插件启动

Qigsaw 完成"山寨" Play Core Library 公开接口实现,因此插件启动实现您可以阅读官方开发文档。

另外,您也可以参考QigsawInstaller实现,该文件实践了插件启动核心逻辑和注意事项。

//初始化SplitInstallManager
SplitInstallManager installManager = SplitInstallManagerFactory.create(this);

//创建一个新的安装请求
SplitInstallRequest request = SplitInstallRequest.newBuilder().addModule(name).build();

//installManager获取请求,请求将交由SplitInstallService进行处理,该过程可设置监听
installManager.startInstall(request);

监听设置及具体实现可参考源码中的qigsaw-android-sample进行。

如何热修插件

Qigsaw本身支持插件的热更新,用户无需重新安装base Apk就可以下载更新插件。Qigsaw不支持AndroidManifest修改,例如新增四大组件。

Qigsaw插件更新步骤

创建插件更新所需分支

为你的代码创建插件更新所需的分支。

修改插件版本号

完成插件代码更新后,在dynamicfeature/build.gradle文件中修改插件版本号(如果不修改插件版本号插件将无法更新)。

android {
    compileSdkVersion 28
    defaultConfig {
        minSdkVersion 14
        targetSdkVersion 28
        versionCode 1
        //versionName "1.0.0"
        versionName "1.0.1"
    }
}

配置旧产物并修改Split-Info版本号

  1. 配置mapping文件。
  2. 配置old apk,存放路径与mapping文件目录一致。
  3. 修改插件信息版本号。
qigsawSplit {

    /**
     * 可选项,默认为null
     * 如果需要更新,需要将上一版本的apk放在指定目录下
     */
    oldApk = "./qigsaw/app.apk"

    /**
     * 设置插件信息版本号,默认为‘1.0.0’
     * 每次通过qigsawAssembleDebug或者qigsawAssembleRelease命令编译时,会在app/build/outputs/apk/debug(or release)/目录下生产插件信息文件,文件名为"qigsaw_appVersionCode_splitInfoVersion.json"
     * 插件更新时必须修改该版本号
     */
    //splitInfoVersion ‘1.0.0’
    splitInfoVersion1.0.1/**
     * 当插件需要更新时,需要应用当前版本渠道包对应的mapping文件。
     */
    applyMapping projectDir.getPath() + '/qigsaw/mapping.txt'

}
  • mapping文件应用规则

插件首次更新,应用app首次发布生成的mapping文件。

插件二次更新,应用插件第一次更新生成的mapping文件。以此类推。

  • old apk应用规则

插件更新情况下,始终应用app首次发布生成的apk文件。

  • 关于不同渠道

某些应用不同渠道其mapping文件不一样,因此需要针对这些渠道分别打包发布。

上传插件信息文件

上传新生成的插件信息JSON文件至您的发布后台。

完成插件更新打包配置后,开发者需在app运行时适时调用Qigsaw#updateSplits方法,传入新的插件信息版本号及新版本json文件的路径,updateSplits将更新插件版本号,并在插件下一次启动时判断是否有更新,如有则进行下载安装:

public static boolean updateSplits(Context context,
                                       @NonNull String newSplitInfoVersion,
                                       @NonNull String newSplitInfoPath)

Qigsaw和Tinker结合问题

国内很多App都已接入Tinker,并在热修方面发挥着很大的作用。兼容性方面,Qigsaw与Tinker能够达到完美结合,但是需要Tinker的版本支持AAPT2

此外通过Tinker也能够更新Qigsaw的插件,在爱奇艺重磅开源基于Android-App-Bundle动态化方案Qigsaw文章中有相关介绍。

Qigsaw-Gradle-Plugin会将与Qigsaw相关Gradle项目配置信息注入至App工程的BuildConfig中,如下。

public final class BuildConfig {
    public static final boolean DEBUG = Boolean.parseBoolean("true");
    public static final String APPLICATION_ID = "com.iqiyi.qigsaw.sample";
    public static final String BUILD_TYPE = "debug";
    public static final String FLAVOR = "";
    public static final int VERSION_CODE = 271;
    public static final String VERSION_NAME = "1.0.0";
    /**
     * split-info版本号。形式为appVersion_splitInfoVerison。
     */
    public static final String DEFAULT_SPLIT_INFO_VERSION = "1.0.0_1.0.0";
    /**
     * qigsawId,用于匹配split apks和base apk关系。
     * 其规则是app版本号加上当前git的commit id。
     */
    public static final String QIGSAW_ID = "1.0.0_ddddf54";
    /**
     * 记录所有split名字。
     */
    public static final String[] DYNAMIC_FEATURES = {"java", "assets", "native"};
}

Tinker Patch打包过程中BuildConfig内容会被改变,因此您需要忽略BuildConfig内容改变。在tinker.gradle文件增加如下配置。

        dex {
            ......
            
            /**
             * necessary,default '[]'
             * Warning, it is very very important, loader classes can't change with patch.
             * thus, they will be removed from patch dexes.
             * you must put the following class into main dex.
             * Simply, you should add your own application {@code tinker.sample.android.SampleApplication}
             * own tinkerLoader, and the classes you use in them
             */
            
            //指app工程生成的BuildConfig.java文件。
            loader = ["${yourPackageNameInAppManifestFile}.BuildConfig"
            ]
        }

loader配置${yourPackageNameInAppManifestFile}.BuildConfig类,yourPackageNameInAppManifestFile指的是您app工程manifest文件所声名的包名。

常见问题答疑

  • Q:“必须实现SplitApkUploader才能将split APK上传到您的CND服务器。”描述中的CND服务器,是指我们自己搭建的服务器还是各大应用市场的?

    **A:**接入方需要将插件Apk上传到自己的CDN服务器进行管理。

  • Q:qigsaw到底是组件化框架还是插件化框架?

    **A:**您可以称它为动态组件化技术。

  • Q:qigsaw对android5.0以下不支持的split apk是如何做到兼容的?

    **A:**qigsaw只是利用 AAB 开发套件,并没有采用和它一样的插件加载方式。采取和一般插件化框架类似的动态加载技术。

  • Q:提交到国内应用市场的是aab文件还是apk文件?

    **A:**提交到应用市场的是apk文件。

  • Q:上传插件必须实现吗,以及必须用groovy写吗?

    **A:**开发者必须实现上传插件才能实现动态下发,groovy与Java是兼容的,可以使用Java语法代替。

  • Q:base Apk中通过getResources().getIdentifier访问插件资源为什么访问不了?

    A:

     getResources().getIdentifier("hello_world","string", getPackageName() + "." + "${插件名}");
    
    

    第三参数参入的资源文件包名需要加上插件名。

  • Q:为什么打debug包不会报错打release包会出现报错?

    assembleRelease_error

    **A:**插件必须配置签名,打debug包如果未配置签名系统会采用默认的签名,但打release包必须使用开发者自己的签名。

  • Q:打包过程中插件中包含多CPU架构平台的so为什么会打包失败?

    abi_error

    **A:**该错误是没有在该插件配置abi过滤导致的,Qigsaw 目前只支持单CPU架构平台的so。

    android{
        defaultConfig{
            ndk{
                abiFilter 'armeabi-v7a'
            }
        }
    }
    

支持

  1. qigsaw-android-sample中可了解更多信息。
  2. 查看Wiki或FAQ以获取帮助。
  3. 邮箱联系,[email protected]
  4. 加入QQ群了解最新信息。

qigsaw_qq_group_chat