Tinker快速集成

本文使用的是tinker的1.9.6版本,使用gradle方式接入。具体的接入方式可参考官方接入指南
需要特别注意:
1. Tinker不支持修改AndroidManifest.xml,Tinker不支持新增四大组件(1.9.0开始支持新增非export的Activity);
2. 由于Google Play的开发者条款限制,不建议在GP渠道动态更新代码(实际上大概率会过不了审);
3. 在Android N上,补丁对应用启动时间有轻微的影响;
4. 不支持部分三星android-21机型,加载补丁时会主动抛出”TinkerRuntimeException:checkDexInstall failed”;
5. 对于资源替换,不支持修改remoteView,例如transition动画,notification icon以及桌面图标;
6. 目前tinker热更新so包的话,只支持armeabi目录下的库文件。

引入tinker

在项目根build.gradle中:

buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:2.3.1'
        // 引入tinker插件
        classpath "com.tencent.tinker:tinker-patch-gradle-plugin:1.9.6"
    }
}

在项目app的build.gradle里配置基本引入:

// 用git号做TINKER_ID
def getTinkerIdValue() {
    return hasProperty("TINKER_ID") ? TINKER_ID : gitSha()
}

def gitSha() {
    try {
        String gitRev = 'git rev-parse --short HEAD'.execute(null, project.rootDir).text.trim()
        if (gitRev == null) {
            throw new GradleException("can't get git rev, you should add git to system path or just input test value, such as 'testTinkerId'")
        }
        return gitRev
    } catch (Exception e) {
        throw new GradleException("can't get git rev, you should add git to system path or just input test value, such as 'testTinkerId'")
    }
}

android {
    ...
    defaultConfig {
        ...
        // 配置TINKER_ID
        buildConfigField "String", "TINKER_ID", "\"${getTinkerIdValue()}\""

        // 必须开启multiDex
        multiDexEnabled true
    }
    // tinker建议开启此项,否则若Application所直接使用的类没有被打在main dex中,就会导致patch失败
    dexOptions {
        // 忽略方法数限制的检查
        jumboMode = true
    }

    sourceSets {
        main {
            ...
            // 若要热修复so包,需要指明so包路径
            jniLibs.srcDirs = ['libs']
        }
    }
}

dependencies {
    compile fileTree(include: ['*.*'], dir: 'libs')
    ...

    // 若使用>=3的gradle,tinker_version=1.9.6
    implementation "com.android.support:multidex:1.0.1"
    implementation("com.tencent.tinker:tinker-android-lib:${rootProject.ext.tinker_version}") {
        changing = true
    }

    // 用于编译时生成application类
    annotationProcessor("com.tencent.tinker:tinker-android-anno:${rootProject.ext.tinker_version}") {
        changing = true
    }
    compileOnly("com.tencent.tinker:tinker-android-anno:${rootProject.ext.tinker_version}") {
        changing = true
    }

    // 若使用<3的gradle
    //compile "com.android.support:multidex:1.0.1"
    //compile("com.tencent.tinker:tinker-android-lib:${rootProject.ext.tinker_version}") {
    //    changing = true
    //}
    //provided("com.tencent.tinker:tinker-android-anno:${rootProject.ext.tinker_version}") {
    //    changing = true
    //}
}

// 其他签名啊,混淆啊啥的都正常配置就行

代码引入方面,此处基本可以照搬示例代码:
这里写图片描述
在Application接入时,Tinker借助注解来生成Application

/**
* 用于生成实际Application的注解类,这样做是因为,所有与Application直接使用的类都需要被打在主dex中。
* 同时,尽量避免在Application中做多余的工作。
*/
@SuppressWarnings("unused")
@DefaultLifeCycle(
    // 定义生成的Application类,与AndroidManifest中的application节点name属性一致,为避免错误,必须是全名
    application = "cn.com.bluemoon.delivery.AppContext",
    // TINKER_ENABLE_ALL:支持dex、lib(so包)、资源的更新
    flags = ShareConstants.TINKER_ENABLE_ALL,
    // 在加载时是否校验dex、lib与res的md5,默认false
    loadVerifyFlag = false
    // loaderClass: 定义tinker的类加载器,默认为TinkerLoader)
public class HFApplicationLike extends DefaultApplicationLike {
    ...
    public HFApplicationLike(Application application, int tinkerFlags, boolean tinkerLoadVerifyFlag,
                             long applicationStartElapsedTime, long applicationStartMillisTime, Intent tinkerResultIntent) {
        super(application, tinkerFlags, tinkerLoadVerifyFlag, applicationStartElapsedTime, applicationStartMillisTime, tinkerResultIntent);
    }

    /**
     * 在安装tinker前安装multiDex,以避免将tinker放进主dex
     */
    @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
    @Override
    public void onBaseContextAttached(Context base) {
        super.onBaseContextAttached(base);
        // 必须先执行MultiDex的install
        MultiDex.install(base);

        // 赋值全局使用的application单例以及其context,其实就是生成的application的实例
        AppContextHolder.application = getApplication();
        AppContextHolder.context = getApplication();

        TinkerManager.setTinkerApplicationLike(this);

        // 没设置uncaughtExceptionHandler,就用默认的SampleUncaughtExceptionHandler
        TinkerManager.initFastCrashProtect();
        // 必须在tinker安装前设置
        TinkerManager.setUpgradeRetryEnable(true);

        // 设置log的实现,可以使用默认实现
        TinkerInstaller.setLogIml(new MyLogImp());

        // 在加载multiDex后执行,否则就需要将com.tencent.tinker.**手动放到主dex,麻烦
        // 配置tinker其他项
        TinkerManager.installTinker(this);
        Tinker tinker = Tinker.with(getApplication());
    }

    /**
    * 可重写onCreate() 、onLowMemory()、onTrimMemory(int level)、onTerminate()、onConfigurationChanged,对应的调用时机与实际Application中的一致
    */
    @Override
    public void onCreate() {
        super.onCreate();
    }

其中TinkerManager是协助Tinker初始化的辅助类,具体可见sample代码,此处只关注:


public class TinkerManager {
    ...

    public static void installTinker(ApplicationLike appLike) {
        if (isInstalled) {
            TinkerLog.w(TAG, "install tinker, but has installed, ignore");
            return;
        }
        // Tinker在加载补丁时的一些回调,默认实现为DefaultLoadReporter
        LoadReporter loadReporter = new SampleLoadReporter(appLike.getApplication());
        // Tinker在修复或者升级补丁时的一些回调,默认实现为DefaultPatchReporter
        PatchReporter patchReporter = new SamplePatchReporter(appLike.getApplication());
        // 用来过滤Tinker收到的补丁包的修复、升级请求,也就是决定是不是真的要唤起:patch进程去尝试补丁合成,默认实现为DefaultPatchListener
        PatchListener patchListener = new SamplePatchListener(appLike.getApplication());
        // 用来升级当前补丁包的处理类,一般来说不需要复写
        AbstractPatch upgradePatchProcessor = new UpgradePatch();

        TinkerInstaller.install(appLike,
            loadReporter, patchReporter, patchListener,
            SampleResultService.class, upgradePatchProcessor);

        isInstalled = true;
    }
}

详细的配置说明可见Tinker自定义扩展

最后,需要在AndroidManifest中做做些微修改:

<!-- 权限添加 -->
   <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
   <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

<!-- name的值与ApplicationLike的application值一致  -->
<application
    android:name=".AppContext"
    ...>
        <!-- :patch补丁合成进程将合成结果返回给主进程的类,默认实现为DefaultTinkerResultService  -->
       <service
           android:name=".tinker.service.SampleResultService"
           android:exported="false"/>
</application>

配置patch

patch的配置在app的build.gradle中定义,详细的配置可见官方指南

// 生成备份的apk、混淆mapping文件、资源R文件的目录,其实就是将每次build出来的相应文件以别名备份
def bakPath = file("${buildDir}/bakApk/")
// bakPath里的备份包名称,用于与新包作比较得出补丁包
def curApkName = "月亮天使_20180514_09_测试版_4.9.4(1726)_0514-09-20-59"

/**
 * 手动的话,可以先使用assembleRelease编译出基础包,
 * 再使用"tinkerPatchRelease -POLD_APK=...  -PAPPLY_MAPPING=...  -PAPPLY_RESOURCE=..."命令生成补丁包
 */
ext {
    // 是否执行tinker。开发期间,可以将这个关了来使用instant run。btw,目前instant run与tinker是互斥的。
    tinkerEnabled = true

    // 旧包(用于打补丁的基础旧包)apk名
    tinkerOldApkPath = "${bakPath}/${curApkName}.apk"
    // 旧包mapping文件
    tinkerApplyMappingPath = "${bakPath}/${curApkName}-mapping.txt"
    // 旧包R文件
    tinkerApplyResourcePath = "${bakPath}/${curApkName}-R.txt"

    //only use for build all flavor, if not, just ignore this field,暂未用过
    tinkerBuildFlavorDirectory = "${bakPath}/app-1018-17-32-47"
}

// 以下都是一些辅助方法(start) /
def getOldApkPath() {
    return hasProperty("OLD_APK") ? OLD_APK : ext.tinkerOldApkPath
}

def getApplyMappingPath() {
    return hasProperty("APPLY_MAPPING") ? APPLY_MAPPING : ext.tinkerApplyMappingPath
}

def getApplyResourceMappingPath() {
    return hasProperty("APPLY_RESOURCE") ? APPLY_RESOURCE : ext.tinkerApplyResourcePath
}

// 用git号做版本号
def gitSha() {
    try {
        String gitRev = 'git rev-parse --short HEAD'.execute(null, project.rootDir).text.trim()
        if (gitRev == null) {
            throw new GradleException("can't get git rev, you should add git to system path or just input test value, such as 'testTinkerId'")
        }
        return gitRev
    } catch (Exception e) {
        throw new GradleException("can't get git rev, you should add git to system path or just input test value, such as 'testTinkerId'")
    }
}

def getTinkerIdValue() {
    return hasProperty("TINKER_ID") ? TINKER_ID : gitSha()
}

def buildWithTinker() {
    return hasProperty("TINKER_ENABLE") ? Boolean.parseBoolean(TINKER_ENABLE) : ext.tinkerEnabled
}

def getTinkerBuildFlavorDirectory() {
    return ext.tinkerBuildFlavorDirectory
}
// 以上都是一些辅助方法(end) /

if (buildWithTinker()) {
    // 引入tinker的patch插件
    apply plugin: 'com.tencent.tinker.patch'

    // tinkerPatch参数配置 
    tinkerPatch {
        /**
         * 必填,默认为null
         * 旧包路径,用作与新包比对后得出差分包
         * 一般是build/bakApk下
         */
        oldApk = getOldApkPath()
        /**
         * 选填,默认为false
         * 以下情况会出现warnings,若设置ignoreWarning为true,只会assert加载补丁进程:
         * 1、minSdkVersion < 14, 但设置了dexMode为raw,此骚操作在加载补丁时必崩;
         * 2、在AndroidManifest.xml中添加新的四大组件(1.9.0版本以上非export的Activity除外),同上,必崩;
         * 3、在以下dex.loader中的用于加载补丁的类没有在主dex中,这样的话,tinker不会起作用;
         * 4、dex.loader中的用于加载补丁的类修改,加载器类是用来加载补丁的,在新包修改的话也不会起作用。此操作不会引发崩溃,但也不会起效,可以忽略;
         * 5、resources.arsc变更了,但没有设置applyResourceMapping(applyResourceMapping=null)用于编译
         */
        ignoreWarning = false

        /**
         * 选填,默认为true
         * 是否需要对补丁签名
         * false时需要手动签名,否则在加载补丁时无法通过检查
         * 这里使用的是android.buildTypes.xx.signingConfig的配置
         */
        useSign = true

        /**
         * 选填,默认为true
         * 此处同ext.tinkerEnabled 
         */
        tinkerEnable = buildWithTinker()

        /**
         * applyMapping会影响正常的编译
         */
        buildConfig {
            /**
             * 选填,默认为null
             * 若使用tinkerPatch命令去打补丁包, 并且开启了minifyEnabled混淆,建议用旧包的mapping文件。
             * 在编译新的apk时候,通过保持旧apk的proguard混淆方式,从而减少补丁包的大小。
             * 警告:
             * 此操作会影响正常的编译过程
             */
            applyMapping = getApplyMappingPath()
            /**
             * 选填,默认为null
             * 使用旧apk的R.txt文件保持ResId的分配,这样不仅可以减少补丁包的大小,同时也避免由于ResId改变导致remote view异常。
             */
            applyResourceMapping = getApplyResourceMappingPath()

            /**
             * 必填,默认为null
             * 由于不希望在运行时检测基础apk(旧包)的md5(慢),
             * 这里在打补丁包的时候,使用了tinkerId来标识基础apk的版本。
             * 此处使用的是gitSha(),同时,thinkerId会自动的被写到AndroidManifest中
             */
            tinkerId = getTinkerIdValue()

            /**
             * 若为true,多dex时会按照基准包的类分布来编译,可以减少dex差分包大小。低版本的tinker此选项开启后有bug。
             */
            keepDexApply = false

            /**
             * 选填,默认为false
             * 是否使用加固模式,仅仅将变更的类合成补丁。注意,这种模式仅仅可以用于加固应用中。
             */
            isProtectedApp = false

            /**
             * 选填,默认为false
             * 是否支持新增非export的Activity,只有在再次启动加载补丁后,此Activity才起作用
             */
            supportHotplugComponent = true
        }

        // dex相关的配置项
        dex {
            /**
             * 选填,默认为jar
             * 取值为raw或jar, 
             * raw模式会保持输入dex的格式,
             * jar模式,会把输入dex重新压缩封装到jar,如果minSdkVersion<14,必须选择jar模式,而且它更省存储空间,但是验证md5时比raw模式耗时。默认并不会去校验md5,一般情况下选择jar模式即可。
            dexMode = "jar"

            /**
             * 必填,默认为[]
             * 需要处理dex路径,支持*、?通配符,必须使用'/'分割。
             * 路径是相对安装包的,例如assets/...
             */
            pattern = ["classes*.dex",
                       "assets/secondary-dex-?.jar"]

            /**
             * 必填,默认为[]
             * 此项非常重要,它定义了哪些类在加载补丁包的时候会用到。这些类是通过tinker无法修改的类,也是一定要放在main dex的类。
             * 必须把以下的类放进这里:
             * 1、自定义的Application类(为避免版本问题,此处最好把ApplicationLike生成的Application也加进来);
             * 2、Tinker库中用于加载补丁包的部分类,即com.tencent.tinker.loader.*; 
             * 3、若自定义了TinkerLoader,需要将它以及它引用的所有类也加入loader中;
             * 4、其他一些不希望被更改的类,例如Sample中的BaseBuildInfo类。这里需要注意的是,这些类的直接引用类也需要加入到loader中,或者需要将这个类变成非preverify。
             * 5、使用1.7.6版本之后的gradle版本,参数1、2会自动填写。若使用newApk或者命令行版本编译,1、2依然需要手动填写
             */
            loader = [
                    "cn.com.bluemoon.delivery.AppContext",
                    //use sample, let BaseBuildInfo unchangeable with tinker
                    "cn.com.bluemoon.delivery.tinker.app.BaseBuildInfo"
            ]
        }

        // lib相关的配置项
        lib {
            /**
             * 选填,默认为[]
             * 需要处理的lib路径,支持*、?通配符,必须使用'/'分割。与dex.pattern一致, 路径是相对安装包的。
             * 对于在assets中的lib,tinker只在补丁包中回复它,可以在TinkerLoadResult中拿到
             */
            pattern = ["lib/*/*.so"]
        }

        // res相关的配置项
        res {
            /**
             * 选填,默认为[]
             * 需要处理的res路径,支持*、?通配符,必须使用'/'分割。与dex.pattern一致, 路径是相对安装包的。
             * 所有的资源文件都必须包含进来,否则否则不会再新包中被重新打包
             */
            pattern = ["res/*", "assets/*", "resources.arsc", "AndroidManifest.xml"]

            /**
             * 选填,默认为[]
             * 支持*、?通配符,必须使用'/'分割。若满足ignoreChange的pattern,在编译时会忽略该文件的新增、删除与修改。 
             * 只能用于不与resources.arsc相关联的文件
             */
            ignoreChange = ["assets/sample_meta.txt"]

            /**
             * 默认100kb
             * 对于修改的资源,如果大于largeModSize,将使用bsdiff算法。这可以降低补丁包的大小,但是会增加合成时的复杂度。
             */
            largeModSize = 100
        }

        // 用于生成补丁包中的'package_meta.txt'文件
        packageConfig {
            /**
             * 选填
             * 
             * we will get the TINKER_ID from the old apk manifest for you automatic,
             * other config files (such as patchMessage below)is not necessary
             * 默认自动从基准安装包与新安装包的Manifest中读取tinkerId,并自动写入configField。在这里,可以定义其他的信息,在运行时可以通过自定义的ownPackageCheck方法里的securityCheck.getPackageProperties()或TinkerLoadResult.getPackageConfigByName得到相应的数值。但是建议直接通过修改代码来实现,例如BuildConfig。
             * 以下都是例子
             */
            configField("patchMessage", "tinker is sample to use")
            configField("platform", "all")
            configField("patchVersion", "1.0")
        }

        /**
         * 7zip路径配置项,执行前提是useSign为true
         * 若不使用zipArtifact或者path, 会自动试用7za。
         */
        sevenZip {
            /**
             * 选填,默认'7za'
             * 例如"com.tencent.mm:SevenZip:1.1.10",将自动根据机器属性获得对应的7za运行文件,推荐使用。
             */
            zipArtifact = "com.tencent.mm:SevenZip:1.1.10"
            /**
             * 选填,默认'7za'
             * 系统中的7za路径,例如"/usr/local/bin/7za"。path设置会覆盖zipArtifact,若都不设置,将直接使用7za去尝试。
             */
//        path = "/usr/local/bin/7za"
        }
    }
}

// 多flavors
List<String> flavors = new ArrayList<>()
project.android.productFlavors.each { flavor ->
    flavors.add(flavor.name)
}
boolean hasFlavors = flavors.size() > 0
def date = new Date().format("MMdd-HH-mm-ss")

/** 
 * 备份任务
 */
android.applicationVariants.all { variant ->
    def taskName = variant.name
    tasks.all {
        if ("assemble${taskName.capitalize()}".equalsIgnoreCase(it.name)) {
            it.doLast {
                copy {
                    def fileNamePrefix = "${project.name}-${variant.baseName}"
                    // 备份文件名
                    def newFileNamePrefix = hasFlavors ? "${fileNamePrefix}" : "${fileNamePrefix}-${date}"

                    // 将生成的apk包备份到bak文件夹下,以newFileNamePrefix.apk的名字
                    def destPath = hasFlavors ? file("${bakPath}/${project.name}-${date}/${variant.flavorName}") : bakPath
                    from variant.outputs.first().outputFile
                    into destPath
                    rename { String fileName ->
                        fileName.replace("${fileNamePrefix}.apk", "${newFileNamePrefix}.apk")
                    }

                    // 同理,备份mapping.txt 
                    from "${buildDir}/outputs/mapping/${variant.dirName}/mapping.txt"
                    into destPath
                    rename { String fileName ->
                        fileName.replace("mapping.txt", "${newFileNamePrefix}-mapping.txt")
                    }

                    // 同理,备份R.txt 
                    from "${buildDir}/intermediates/symbols/${variant.dirName}/R.txt"
                    into destPath
                    rename { String fileName ->
                        fileName.replace("R.txt", "${newFileNamePrefix}-R.txt")
                    }
                }
            }
        }
    }
}

// 以下为多flavors打包用到的,本文未涉及
project.afterEvaluate {
    if (hasFlavors) {
        task(tinkerPatchAllFlavorRelease) {
            group = 'tinker'
            def originOldPath = getTinkerBuildFlavorDirectory()
            for (String flavor : flavors) {
                def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Release")
                dependsOn tinkerTask
                def preAssembleTask = tasks.getByName("process${flavor.capitalize()}ReleaseManifest")
                preAssembleTask.doFirst {
                    String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 15)
                    project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release.apk"
                    project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-mapping.txt"
                    project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-R.txt"

                }

            }
        }

        task(tinkerPatchAllFlavorDebug) {
            group = 'tinker'
            def originOldPath = getTinkerBuildFlavorDirectory()
            for (String flavor : flavors) {
                def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Debug")
                dependsOn tinkerTask
                def preAssembleTask = tasks.getByName("process${flavor.capitalize()}DebugManifest")
                preAssembleTask.doFirst {
                    String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 13)
                    project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug.apk"
                    project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-mapping.txt"
                    project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-R.txt"
                }

            }
        }
    }
}

加上使用补丁包代码

使用的时候,通常用的是生成的7zip包,在得到补丁包后,调用:

TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(), 
// 补丁包绝对路径
Environment.getExternalStorageDirectory().getAbsolutePath() + "/Download/patch_signed_7zip.apk");

若要手动清除补丁,可调用:

// 清除补丁
Tinker.with(getApplicationContext()).cleanPatch();

打出补丁包

  1. 接入、配置好tinker后,使用 assemblexxx(如assembleDeug)编译出基础包(旧包);
  2. 修改代码,并将app的build.gradle中的curApkName改为bak文件夹里的备份基础包名(不含后缀);
  3. 使用tinkerPatchxxx(如tinkerPathDebug)命令,即可得出补丁文件:
    这里写图片描述
    生成文件中主要关注的是:
文件名描述
patch_unsigned.apk没有签名的补丁包
patch_signed.apk签名后的补丁包
patch_signed_7zip.apk签名后并使用7zip压缩的补丁包,也是我们通常使用的补丁包。但正式发布的时候,最好不要以.apk结尾,防止被运营商挟持
log.txt在编译补丁包过程的控制台日志
dex_log.txt在编译补丁包过程关于dex的日志
so_log.txt在编译补丁包过程关于lib的日志
tinker_result最终在补丁包的内容,包括diff的dex、lib以及assets下面的meta文件
resources_out.zip最终在手机上合成的全量资源apk,你可以在这里查看是否有文件遗漏
tempPatchedDexes在Dalvik与Art平台,最终在手机上合成的完整Dex,我们可以在这里查看dex合成的产物

检验成果

将补丁文件保存到设备,如上上节中的Environment.getExternalStorageDirectory().getAbsolutePath() + “/Download/patch_signed_7zip.apk”,调用TinkerInstaller.onReceiveUpgradePatch即可加载补丁,加载完成的回调中一般需要将补丁包删除。等待patch进程完成后,重新启动应用(必须完全kill掉进程重新启动),即可完成补丁加载。

参考与深度阅读

你必须知道的APT、annotationProcessor、android-apt、Provided、自定义注解
类加载机制系列2——深入理解Android中的类加载器
Android热修复技术选型——三大流派解析
Tinker 接入指南
Android 热修复 Tinker接入及源码浅析

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值