Tinker-使用教程与原理分析(上)

前言

前面我们讲解了AndFix的使用,这篇我们来讲解下微信的Tinker热修复,相比AndFix,Tinker的功能更加全面,更主要的是他支持gradle。他不仅做到了热修复更实现了“热更新”。既然他这么强大,下面我们就来了解他是如何使用的。


命令行生成补丁文件

在学习AndFix时由于它不自持Gradle,所以我们在生成补丁文件时是需要命令行去生成的。然而Tinker不仅支持Gradle同时也支持命令行生成补丁文件。不过在实际开发中,我们往往是使用Gradle去生成补丁文件,同时去配置一些需要的参数与属性。不过既然我们想详细了解它那么我们还是讲解下命令行生成补丁文件。

建议:无论学习什么技术,以官方文档为主,教程文章为辅。这样会好一些。

  1. 引入依赖
·
    //注解库 用于生成application类 provided编译不打包
    provided('com.tencent.tinker:tinker-android-anno:1.7.7'){ changing = true }
    //是否将依赖关系标记为正在改变
    //tinker的核心库 compile编译并打包
    compile('com.tencent.tinker:tinker-android-lib:1.7.7'){ changing = true }

命令行相对简单。首先我们要引入两个依赖。

  1. 创建ApplicationLike代理类 这里我们创建TinkerManager来实现对Tinker的管理。 TinkerManager:
/**
 * 功能   :Tinker管理类
 */

public class TinkerManager {

    private static boolean isInstalled = false;//是否已经初始化标志位
    private static ApplicationLike mApplicationLike;

    /**
     * 完成Tinker初始化
     *
     * @param applicationLike
     */
    public static void installedTinker(ApplicationLike applicationLike) {
        mApplicationLike = applicationLike;
        if (isInstalled) {
            return;
        }
        TinkerInstaller.install(mApplicationLike);
        isInstalled = true;
    }

    /**
     * 完成patch文件的加载
     *
     * @param path 补丁文件路径
     */
    public static void loadPatch(String path) {
        if (Tinker.isTinkerInstalled()) {//是否已经安装过
            TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(), path);
        }
    }

    /**
     * 利用Tinker代理Application 获取应用全局的上下文
     * @return 全局的上下文
     */
    private static Context getApplicationContext() {
        if (mApplicationLike != null)
            return mApplicationLike.getApplication().getApplicationContext();
        return null;
    }
}

这里我们不是自己创建Application。而是使用Tinker为我们提供的ApplicationLike(也可以继承DefaultApplicationLike),作用已经有注释了。同时必须实现它的构造方法。并且我们重写了onBaseContextAttached这个方法并在里面初始化Tinker。完成这个后我们需要同步,然后就会生成MyTinkerApplication这个类(同步后没有出现这个类可以rebuild项目)。代码如下:

`
/**
 * 功能   :ApplicationLike为Tinker生成Context对象 官方建议 而不是继承我们自己的Application
 * 作用   :使用这个ApplicationLike这个类作为Application的委托代理是因为,Tinker需要监听Application
 * 的生命周期并针对不同的生命周期来做相应的初始化与处理,这样就减少使用者需要自己处理。
 */
@DefaultLifeCycle(application = ".MyTinkerApplication" ,
        flags = ShareConstants.TINKER_ENABLE_ALL,
        loadVerifyFlag = false)//都是官方要求这么写的
public class CustomTinkerLike extends ApplicationLike {
    public CustomTinkerLike(Application application,
                            int tinkerFlags,
                            boolean tinkerLoadVerifyFlag,
                            long applicationStartElapsedTime,
                            long applicationStartMillisTime,
                            Intent tinkerResultIntent) {
        super(application,
                tinkerFlags,
                tinkerLoadVerifyFlag,
                applicationStartElapsedTime,
                applicationStartMillisTime,
                tinkerResultIntent);
    }

    @Override
    public void onBaseContextAttached(Context base) {
        super.onBaseContextAttached(base);

        TinkerManager.installedTinker(this);
    }
}

3.在Manifest.xml中配置

·
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="com.example.ggxiaozhi.tinker">

    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

    <application
        android:name=".tinker.MyTinkerApplication"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>

                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <!-- 这个标签开判断我们生成的patch的.apk文件中的tinker_id_XXX
        与我们的版本号tinker_id_XXX比较。相同合法,不同则不会进行更新 -->
        <meta-data
            android:name="TINKER_ID"
            android:value="tinker_id_19940208"/>
    </application>

</manifest>

首先我们加上必要的权限。然设置我们的MyTinkerApplication。同时我们还需要配置TINKER_ID这个属性,value值的数字部分一般为我们的versionCode。

  1. 生成差异apk文件 在完成配置后我们需要生成一个old.apk(也就是需要修复的apk)。代码如下: MainActivity.xml:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context="com.example.ggxiaozhi.tinker.MainActivity">

    <Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:onClick="loadPatch"
        android:text="修复BUG"/>
</LinearLayout>

MainActivity:

public class MainActivity extends AppCompatActivity {

    private static final String FILE_END = ".apk";//文件后缀
    private String FILEDIR;//文件路径
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        //    /storage/emulated/0/Android/data/com.example.ggxiaozhi.tinker/cache/tpatch/
        FILEDIR = getExternalCacheDir().getAbsolutePath() + "/tpatch/";
        //创建路径对应的文件夹
        File file = new File(FILEDIR);
        if (!file.exists())
            file.mkdir();
    }

    public void loadPatch(View view) {
        TinkerManager.loadPatch(getPatchName());
    }

    public String getPatchName() {
        return FILEDIR.concat("tinker").concat(FILE_END);
    }
}

这是old.apk中的代码。布局与代码也非常简单就是创建补丁文件的路径,在点击按钮时加载补丁文件。然后我就开始打包带签名文件的old.apk。这里我就不带大家打包了。打包完成后,我们修改下布局:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context="com.example.ggxiaozhi.tinker.MainActivity">

    <Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:onClick="loadPatch"
        android:text="修复BUG"/>
    <Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="测试按钮"/>
</LinearLayout>

这里我们其他代码不动,只是增加了一个按钮。同时我们在打包一个新的new.apk文件出来。并将两个文件和签名文件。同时copy到命令行工具中。

  1. 命令行生成补丁文件 首先我们利用Tinker官方为我们提供的命令行工具目录如下:

图片.png 将上面我们生成的两个apk文件重命名并将签名文件copy到该目录下。(注意.keystore是eclipse的签名文件.jks是AndroidStudio的签名文件,可以直接修改后缀名,并不影响)然后我们输入一下命令:

java -jar tinker-patch-cli.jar -old old.apk -new new.apk -config tinker_config.xml -out output_path

生成补丁文件。具体如下:

clipboard.png

aass.png

output为我们补丁文件的输出文件夹,不存在会自动创建。输入完命令后output文件夹如下:

图片.png

patch_signed.apk文件就是我们的补丁文件。然后我们安装old.apk并将这个补丁文件通过命令或是拷贝我们之前创建的指定文件下并重命名成我们代码中写的tinker.apk。这样点击按钮就会完成修复。 注意,在点击后会杀到当前进程,需要重新进入后才能看到效果。官方建议我们去监听手机的广播,比如锁屏的广播,点击HOME键等。来去重新启动,这个问题后面我们再去优化


gradle生成补丁文件

文章开始我们就说过在实际中,我们是通常是以gradle生成补丁文件较多。当然网上也有很多配置教程,基本上大同小异。下面我们来在上面代码的基础上修改,来完成gradle生成补丁文件。首先我们先修改下Manifest.xml:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="com.example.ggxiaozhi.tinker">

    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

        ...
        
        <!-- 这个标签开判断我们生成的patch的.apk文件中的tinker_id_
        与我们的版本号tinker_id_比较。相同合法,不同则不会进行更新 -->
        <!--<meta-data
            android:name="TINKER_ID"
            android:value="tinker_id_19940208"/>-->
</manifest>

这里我们将tinker_id注释掉,因为我们会在gradle中去配置。然后在最外面的gradle中添加插件。

// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:2.3.3'
        //加入Tinker的插件 里面包含gradle脚本
        classpath "com.tencent.tinker:tinker-patch-gradle-plugin:${TINKER_VERSION}"
        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

其他不同修改,只需加上插件即可。然后我们就可以配置gradle了。关于gradle配置网上有很多,基本上都懂小异。我把我的gradler配置粘贴出来,供大家参考:

apply plugin: 'com.android.application'

/*================================常量块中的引用常量====================================*/

def javaVersion=JavaVersion.VERSION_1_7

//这个目录是基于项目的目录:Tinker/app/build/bakApk目录下存放oldApk
//buildDir : Tinker/app/build/
def bakPath = file("${buildDir}/bakApk/")//指定基准文件(oldApk)存放位置

android {
    compileSdkVersion 25
    buildToolsVersion "25.0.3"
    defaultConfig {
        applicationId "com.example.ggxiaozhi.tinker"
        minSdkVersion 19
        targetSdkVersion 25
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
        multiDexEnabled true

    }
    //排除目录下不需要编译的包
    sourceSets {
        main {
            jniLibs.srcDirs = ['libs']
        }
    }
    //java版本
    compileOptions {
        sourceCompatibility javaVersion
        targetCompatibility javaVersion
    }
    //建议 recommend Tinker相关配置
    dexOptions {
        //启动矩形模式
        jumboMode = true
    }
    signingConfigs {
        release {
            try {
                storeFile file("release.jks")//目录位置app/release.jks
                storePassword "gg199402"
                keyAlias "gg199402"
                keyPassword "gg199402"
            } catch (ex) {
                throw new InvalidUserDataException(ex.toString())
            }
        }
    }
    buildTypes {
        release {
            //是否进行混淆
            minifyEnabled true
            // 混淆文件的位置
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            signingConfig signingConfigs.release
        }
    }

<!--    //真正的多渠道脚本支持
    productFlavors {

        googleplayer {
            manifestPlaceholders = [UMENG_CHANNEL_VALUE: "googleplayer"]
        }

        baidu {
            manifestPlaceholders = [UMENG_CHANNEL_VALUE: "baidu"]
        }

        productFlavors.all {
            flavor -> flavor.manifestPlaceholders = [UMENG_CHANNEL_VALUE: name]
        }
    }-->
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
        exclude group: 'com.android.support', module: 'support-annotations'
    })

    //可选,用于生成application类 provided编译不打包
    provided("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") { changing = true }
    //是否将依赖关系标记为正在改变
    //tinker的核心库 compile编译并打包
    compile("com.tencent.tinker:tinker-android-lib:${TINKER_VERSION}") { changing = true }
    compile "com.android.support:multidex:1.0.1"//分包
    compile 'com.android.support:appcompat-v7:25.3.1'
    compile 'com.android.support.constraint:constraint-layout:1.0.2'
    testCompile 'junit:junit:4.12'
}


ext {
    tinkerEnabled = true  //是否启用Tinker的标志位
    tinkerOldApkPath = "${bakPath}/"//oldApk 文件路径
    tinkerID = "1.0"//与版本号一致
    tinkerApplyMappingPath = "${bakPath}/" //混淆文件路径
    tinkerApplyResourcePath = "${bakPath}/" //资源路径
<!--    tinkerBuildFlavorDirectory = "${bakPath}/"  //多渠道路径-->
}

/*================================方法实现模块====================================*/

def getOldApkPath() {
    return ext.tinkerOldApkPath
}

def getApplyMappingPath() {
    return ext.tinkerApplyMappingPath
}

def getApplyResourceMappingPath() {
    return  ext.tinkerApplyResourcePath
}

def getTinkerIdValue() {
    return ext.tinkerID
}

def buildWithTinker() {
    return ext.tinkerEnabled
}

<!--def getTinkerBuildFlavorDirectory(){

    return ext.tinkerBuildFlavorDirectory
}
-->

if (buildWithTinker()) {
    //启用Tinker  引入相关Gradle方法
    apply plugin: 'com.tencent.tinker.patch'

    //所有Tinker相关参数的配置
    tinkerPatch {

        /*================================基本配置====================================*/

        //指定old apk(即上一个版本的Apk) 的文件路径
        oldApk = getOldApkPath()

        //是否忽略Tinker在产生patch文件时的错误警告并中断编译 false 不忽略 这样可以在生成patch文件时查看错误 具体哪些错误类型查考文档
        ignoreWarning = false

        //patch是否需要签名 true为需要 防止恶意串改
        useSign = true

        //是否启用tinker
        tinkerEnable = buildWithTinker()

        /*================================build配置====================================*/

        buildConfig {

            //指定old apk打包时所使用的混淆文件 (因为patch文件也是需要混淆的 所以必须要与Apk的打包混淆文件一致)
            applyMapping = getApplyMappingPath()

            //指定old apk的资源文件 希望new apk与其保持一致(R.txt 文件保持ResId的分配)
            applyResourceMapping = getApplyResourceMappingPath()

            //指定TinkerID patch文件的唯一标识符 要与新旧Apk一致
            tinkerId = getTinkerIdValue()

            //通常为false true会根据dex分包动态编译patch文件
            keepDexApply = false
        }

        /*================================dex相关配置====================================*/
        dex {

            //Tinker提供两种模式jar、raw
            //jar 适配到了api=14以下 而raw只能再14以上
            //jar模式下 Tinker会对dex文件压缩成jar文件 在对jar进行处理
            //raw模式下 Tinker直接对dex进行处理
            //使用jar文件体积相对会小一些 在实际开发中用jar模式较多
            dexMode = "jar"

            //指定dex目录  "assets/secondary-dex-?.jar"为Tinker官方Demo中建议参数
            //在没有分包的情况下 "classes*.dex" 会匹配到应用中的所有dex文件 分包会是classes1,classes2....
            pattern = ["classes*.dex", "assets/secondary-dex-?.jar"]

            //制定patch文件用到类
            loader = ["com.example.ggxiaozhi.tinker.tinker.MyTinkerApplication"]
        }

        /*================================Tinker关于jar与.so文件的替换相关配置====================================*/

        lib {
            pattern = ["libs/*/*.so"]
        }

        /*================================Tinker关于资源文件替换相关配置====================================*/

        res {

            //指定Tinker可以修改的资源文件路径
            // resources.arcs :AndroidReSourCe也就是与Android资源相关的一种文件格式。
            // 具体角色是提供资源ID到资源文件路径的映射关系,
            // 具体来说就是R.layout.activity_main(0x7f030000)到res/layout/activity_main.xml的映射关系
            // 其中R.layout.activity_main就是应用开发过程中所使用的资源ID
            pattern = ["res/*", "assets/*", "resources.arsc", "AndroidManifest.xml"]
            //在编译时会忽略该文件的新增、删除与修改 即使修改了文件 也不会patch文件生效
            ignoreChange = ["assets/sample_meta.txt"]

            //对于修改的资源,如果大于largeModSize,我们将使用bsdiff算法。
            // 默认大小为100kb
            largeModSize = 100
        }

        /*=============附加说明字段 配置 说明本次Patch文件的相关信息 非必须 packageConfig(官方:用于生成补丁包中的'package_meta.txt'文件)=================*/

        packageConfig {
            /*configField("key","value") 键值对 用于说明 当客户端使用patch文件修复成功 可以通过代码获取下面patch相关信息*/
            configField("patchMessage", "fix the version's bugs")
            configField("patchVersion", "1.0")
        }

        //sevenZip ......
        sevenZip {
            /**
             * 例如"com.tencent.mm:SevenZip:1.1.10",将自动根据机器属性获得对应的7za运行文件,推荐使用
             */
            zipArtifact = "com.tencent.mm:SevenZip:1.1.10"
        }
    }

    /*================================备份脚本 用来将生成的APK的制定文件备份到制定目录====================================*/

    //多渠道相关遍历
    List<String> flavors = new ArrayList<>();
    project.android.productFlavors.each { flavor ->
        flavors.add(flavor.name)
    }
    //如果是多渠道 则size()>0 为true
    boolean hasFlavors = flavors.size() > 0

    /**
     * bak apk and mapping 备份pak与mapping(配置文件)
     */
    android.applicationVariants.all { variant ->
        /**
         * task type, you want to bak 备份你想备份的数据 可以是任意类型
         */
        def taskName = variant.name
        def date = new Date().format("MMdd-HH-mm-ss")

        tasks.all {
            if ("assemble${taskName.capitalize()}".equalsIgnoreCase(it.name)) {

                it.doLast {
                    copy {
                        def fileNamePrefix = "${project.name}-${variant.baseName}"
                        def newFileNamePrefix = hasFlavors ? "${fileNamePrefix}" : "${fileNamePrefix}-${date}"

                        //destPath为备份的目录 没有没有多渠道打包那么hasFlavors为false destPath=bakPath bakPath即最上面定义的基础目录
                        def destPath = hasFlavors ? file("${bakPath}/${project.name}-${date}/${variant.flavorName}") : bakPath
                        from variant.outputs.outputFile
                        into destPath
                        //备份.apk文件
                        rename { String fileName ->
                            fileName.replace("${fileNamePrefix}.apk", "${newFileNamePrefix}.apk")
                        }

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

                        from "${buildDir}/intermediates/symbols/${variant.dirName}/R.txt"
                        into destPath
                        //备份R.txt文件 即用于映射的资源ID
                        rename { String fileName ->
                            fileName.replace("R.txt", "${newFileNamePrefix}-R.txt")
                        }
                    }
                }
            }
        }
    }


     /* Tinker多渠道打包文件拼凑脚本*/
  <!--  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"
                    }
                }
            }
            //Debug签名多渠道打包基本
            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"
                    }
                }
            }
        }
    }-->
}

这里有关多渠道打包的部分我先注释掉,我们先从简单看起。里面注释应该是比较详细的了,在使用中这些配置也基本满足需求。关于参数与配置也可以参考官方文档。sample中的app/build.gradle以及gradle参数详解

做完这些Tinker的gradle接入就完成了。还是之前的代码我们先打一个包含一个button的带签名的正式包。

图片.png

首先我们点击1.生成基准(oldApk)签名包。2.是用来生成补丁文件的。然后我们修改代码,在加入一个Button,也可以同时给加上点击事件Toast。生成apk文件后目录如下:

图片.png

首先我们在app/build/outputs/apk/app-release.apk生成签名文件apk,并备份到在app/build/bakApk/下,并以时间重命名文件。这三个文件分别是基准包(oldApk)、混淆文件、资源文件。然后我们分别将这个文件名写入到我们的gradle中,如下:

ext {
    tinkerEnabled = true  //是否启用Tinker的标志位
    tinkerOldApkPath = "${bakPath}/app-release-0201-16-15-06.apk"//oldApk 文件路径
    tinkerID = "1.0"//与版本号一致
    tinkerApplyMappingPath = "${bakPath}/app-release-0201-16-15-06-mapping.txt" //混淆文件路径
    tinkerApplyResourcePath = "${bakPath}/app-release-0201-16-15-06-R.txt" //资源路径
    
}

只需要修改ext部分其他不变。然后我们点击2.部分生成补丁文件。补丁文件目录文件如下:

图片.png

目录中的参数作用,可以参考下表:

图片.png

然后我们就将基准包安装到手机中,并将补丁文件copy到我们代码中指定的文件夹下并重命名。就可以完成动态更新。我亲测有效。所以就不发动图了。


结语

现在Tinker的版本已经更新到了1.9.2。相对与本文的1.7.7最主要的改动就是支持加固同时也进行了一些优化,比如支持Android8.0 等。由于最新的版本我要使用所以就没有去以最新版本去分析。大家有需要的可以在学习本系列后具体了解下。下篇我们来讲解Gradle生成补丁文件的扩展和优化以及从源码查看流程分析。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏黄日成的专栏

浅析 P2P 穿越 NAT 的原理、技术、方法 ( 下 )

在 NAT 环境下,实现 P2P 通信的完整解决方案包括几个部分呢?相关的原理、方法、技术有哪些?

1.9K20
来自专栏向治洪

React Native移植原生Android

(一)前言 之前已经写过了有关React Native移植原生Android项目的文章,不过因为RN版本更新的原因吧,跟着以前的文章可能会出现一些问题,对于初学...

20570
来自专栏大魏分享(微信公众号:david-share)

用Ansible自动供应vmware虚拟机--构建数据中心一体化运维平台第二篇

1.1 简述 一直以来,打开邮箱被ticket糊一脸的事情时有发生。我一直在想,能不能以一种简单的方案(不花老板的钱)来供应(provisioning)虚拟机呢...

73520
来自专栏逸鹏说道

直传文件到Azure Storage的Blob服务中

题记:为了庆祝获得微信公众号赞赏功能,忙里抽闲分享一下最近工作的一点心得:如何直接从浏览器中上传文件到Azure Storage的Blob服务中。 为什么 如果...

37370
来自专栏移动开发的那些事儿

Android Sqlite并发问题

如上异常堆栈中的错误信息error code 5: database is locked,经过查找发现code为5代表sqlite中的SQLITE_BUSY异常...

18540
来自专栏草根专栏

使用Identity Server 4建立Authorization Server (1)

本文内容基本完全来自于Identity Server 4官方文档: https://identityserver4.readthedocs.io/ 官方文档很详...

533100
来自专栏菩提树下的杨过

ActiveMQ笔记(6):消息延时投递

在开发业务系统时,某些业务场景需要消息定时发送或延时发送(类似:飞信的短信定时发送需求),这时候就需要用到activemq的消息延时投递,详细的文档可参考官网说...

44950
来自专栏互联网杂技

SpringBoot ( 十二 ) :SpringBoot 如何测试打包部署

有很多网友会时不时的问我,spring boot项目如何测试,如何部署,在生产中有什么好的部署方案吗?这篇文章就来介绍一下spring boot 如何开发、调试...

10220
来自专栏乐沙弥的世界

配置共享服务器模式

两者完成相同的任务,即处理所有指定的SQL操作。假定从客户端提交一个任意查询(DQL)到数据库服务器不论是专用模式还是共享

27530
来自专栏沃趣科技

ASM 翻译系列第三十六弹:ACFS磁盘组的重平衡操作

原作者:Bane Radulovic 译者: 魏兴华 审核: 魏兴华 DBGeeK社区联合出品 原文链接:http://asmsupportguy....

375110

扫码关注云+社区

领取腾讯云代金券