微信热补丁解决方案Tinker初探(一)
2017-01-04 20:21
411 查看
微信开源Tinker有一段时间了,这两天上手体验了下,感觉还可以,下面就来说一下我的Tinker初体验。
Tinker是微信官方的Android热补丁解决方案,它支持动态下发代码、So库以及资源,让应用能够在不需要重新安装的情况下实现更新。当然,你也可以使用Tinker来更新你的插件。
它主要包括以下几个部分:
1.gradle编译插件: tinker-patch-gradle-plugin
2.核心sdk库: tinker-android-lib
3.非gradle编译用户的命令行版本: tinker-patch-cli.jar
这是Tinker的官方简介Tinker GitHub(感觉有点啰嗦)
首先需要注意的几点:
1.Tinker不支持修改AndroidManifest.xml,Tinker不支持新增四大组件;
2.由于Google Play的开发者条款限制,不建议在GP渠道动态更新代码;
3.由于apk-parser issue, TinkerId不能设置为非常短的数字,例如“1.0”;
4.在Android N上,补丁对应用启动时间有轻微的影响;
5.不支持部分三星android-19机型,加载补丁时会主动抛出”TinkerRuntimeException:checkDexInstall failed”;
6.关于渠道包的问题,若使用flavor编译渠道包,会导致不同的渠道包由于BuildConfig变化导致classes.dex差异。这里建议的方式有:
将渠道信息写在AndroidManifest.xml中;
将渠道信息写在apk文件的zip comment中,这种是建议方式;
若不同渠道存在功能上的差异,建议将差异部分放于单独的dex或采用相同代码不同配置方式实现。
首先新建一个工程TinkerTest,在app/build.gradle添加如下配置:
添加依赖:
添加如下配置:
关于各个参数信息大家可以参考接入指南
然后新建一个类继承TinkerApplication:
修改AndroidManifest.xml文件:
(注意要新增文件读取权限)
然后新建一个Application主类MyApplication继承DefaultApplicationLike,并且实现onBaseContextAttached方法:
配置完如上信息,现在开始写代码。首先写一个如图的样式布局:
xml代码:
MainActivity代码:
以上
这两行代码是注释掉的,所以必定运行起来必定会报空指针异常。
下面执行gradle assembleDebug 命令,执行完成后,会在app/build 路径下面会生成一个bakApk文件夹,里面有个app-debug-0105-09-44-52.apk文件,将这个apk文件安装到手机里运行,不出所料,点击“显示名字”按钮,应用发生了闪退,日志信息报空指针异常。
然后将刚才注释的两行代码取消注释,将app/build.gradle文件里的
这个文件名修改成刚刚生成的文件名,接着在命令行运行gradle tinkerPatchDebug命令,如果报
这个错误,那就是上述没有修改文件名。
执行完成后,在app/build/outputs下会生成一个tinkerPatch文件夹,将tinkerPatch/debug文件夹下的patch_signed_7zip.apk补丁文件放到手机根目录下(其实只要跟代码里的文件路径保持一致就行),然后点击“加载PATCH”按钮,这里有个坑,希望大家注意,Android6.0以上需要手动申请权限,不然会报如下错误:
Throwable:Tinker Exception:ShareSecurityCheck file /storage/emulated/0/patch_signed_7zip.apk, size 1959 verifyPatchMetaSignature fail
加载补丁成功后,日志会输出
DefaultTinkerResultService received a result: PatchResult: isSuccess:true rawPatchFilePath:/storage/emulated/0/patch_signed_7zip.apk costTime:49
这时候应用会重新运行,点击“显示名字按钮”,就能正常Toast消息了。补丁安装成功。
以上就是第一次接触Tinker的流程,适合第一次接触Tinker的阅读人群,关于一些细节,后面还会详细与大家交流,比如补丁加载成功失败的监听,大家也可以去看官方文档,么么哒~~今天就到这里,想要源码的话就可以联系邮箱juyao0909@gmail.com
Tinker是微信官方的Android热补丁解决方案,它支持动态下发代码、So库以及资源,让应用能够在不需要重新安装的情况下实现更新。当然,你也可以使用Tinker来更新你的插件。
它主要包括以下几个部分:
1.gradle编译插件: tinker-patch-gradle-plugin
2.核心sdk库: tinker-android-lib
3.非gradle编译用户的命令行版本: tinker-patch-cli.jar
这是Tinker的官方简介Tinker GitHub(感觉有点啰嗦)
首先需要注意的几点:
1.Tinker不支持修改AndroidManifest.xml,Tinker不支持新增四大组件;
2.由于Google Play的开发者条款限制,不建议在GP渠道动态更新代码;
3.由于apk-parser issue, TinkerId不能设置为非常短的数字,例如“1.0”;
4.在Android N上,补丁对应用启动时间有轻微的影响;
5.不支持部分三星android-19机型,加载补丁时会主动抛出”TinkerRuntimeException:checkDexInstall failed”;
6.关于渠道包的问题,若使用flavor编译渠道包,会导致不同的渠道包由于BuildConfig变化导致classes.dex差异。这里建议的方式有:
将渠道信息写在AndroidManifest.xml中;
将渠道信息写在apk文件的zip comment中,这种是建议方式;
若不同渠道存在功能上的差异,建议将差异部分放于单独的dex或采用相同代码不同配置方式实现。
首先新建一个工程TinkerTest,在app/build.gradle添加如下配置:
添加依赖:
dependencies { compile 'com.android.support:appcompat-v7:25.1.0' testCompile 'junit:junit:4.12' //optional, help to generate the final application provided('com.tencent.tinker:tinker-android-anno:1.7.6') //tinker's main Android lib compile('com.tencent.tinker:tinker-android-lib:1.7.6') compile "com.android.support:multidex:1.0.1" }
添加如下配置:
def javaVersion = JavaVersion.VERSION_1_7 android { compileSdkVersion 25 buildToolsVersion "25.0.0" defaultConfig { applicationId "com.juyao.tinkertest" minSdkVersion 16 targetSdkVersion 25 versionCode 1 versionName "1.0" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" /** * you can use multiDex and install it in your ApplicationLifeCycle implement */ multiDexEnabled true /** * buildConfig can change during patch! * we can use the newly value when patch */ buildConfigField "String", "MESSAGE", "\"I am the base apk\"" // buildConfigField "String", "MESSAGE", "\"I am the patch apk\"" /** * client version would update with patch * so we can get the newly git version easily! */ buildConfigField "String", "TINKER_ID", "\"${getTinkerIdValue()}\"" buildConfigField "String", "PLATFORM", "\"all\"" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } //recommend dexOptions { jumboMode = true } //签名 signingConfigs { release { storeFile file("key.keystore") storePassword "123456" keyAlias "zj" keyPassword "123456" } debug{ storeFile file("key.keystore") storePassword "123456" keyAlias "zj" keyPassword "123456" } } } def bakPath = file("${buildDir}/bakApk/") /** * you can use assembleRelease to build you base apk * use tinkerPatchRelease -POLD_APK= -PAPPLY_MAPPING= -PAPPLY_RESOURCE= to build patch * add apk from the build/bakApk */ ext { //for some reason, you may want to ignore tinkerBuild, such as instant run debug build? tinkerEnabled = true //for normal build //old apk file to build patch apk tinkerOldApkPath = "${bakPath}/app-debug-0104-16-57-35.apk" //proguard mapping file to build patch apk tinkerApplyMappingPath = "${bakPath}/app-debug-mapping.txt" //resource R.txt to build patch apk, must input if there is resource changed tinkerApplyResourcePath = "${bakPath}/app-debug-R.txt" //only use for build all flavor, if not, just ignore this field tinkerBuildFlavorDirectory = "${bakPath}/app-1018-17-32-47" } 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 } def getTinkerIdValue() { return hasProperty("TINKER_ID") ? TINKER_ID : gitSha() } def buildWithTinker() { return hasProperty("TINKER_ENABLE") ? TINKER_ENABLE : ext.tinkerEnabled } def getTinkerBuildFlavorDirectory() { return ext.tinkerBuildFlavorDirectory } if (buildWithTinker()) { apply plugin: 'com.tencent.tinker.patch' tinkerPatch { /** * necessary,default 'null' * the old apk path, use to diff with the new apk to build * add apk from the build/bakApk */ oldApk = getOldApkPath() /** * optional,default 'false' * there are some cases we may get some warnings * if ignoreWarning is true, we would just assert the patch process * case 1: minSdkVersion is below 14, but you are using dexMode with raw. * it must be crash when load. * case 2: newly added Android Component in AndroidManifest.xml, * it must be crash when load. * case 3: loader classes in dex.loader{} are not keep in the main dex, * it must be let tinker not work. * case 4: loader classes in dex.loader{} changes, * loader classes is ues to load patch dex. it is useless to change them. * it won't crash, but these changes can't effect. you may ignore it * case 5: resources.arsc has changed, but we don't use applyResourceMapping to build */ ignoreWarning = false /** * optional,default 'true' * whether sign the patch file * if not, you must do yourself. otherwise it can't check success during the patch loading * we will use the sign config with your build type */ useSign = true /** * optional,default 'true' * whether use tinker to build */ tinkerEnable = buildWithTinker() /** * Warning, applyMapping will affect the normal android build! */ buildConfig { /** * optional,default 'null' * if we use tinkerPatch to build the patch apk, you'd better to apply the old * apk mapping file if minifyEnabled is enable! * Warning: * you must be careful that it will affect the normal assemble build! */ applyMapping = getApplyMappingPath() /** * optional,default 'null' * It is nice to keep the resource id from R.txt file to reduce java changes */ applyResourceMapping = getApplyResourceMappingPath() /** * necessary,default 'null' * because we don't want to check the base apk with md5 in the runtime(it is slow) * tinkerId is use to identify the unique base apk when the patch is tried to apply. * we can use git rev, svn rev or simply versionCode. * we will gen the tinkerId in your manifest automatic */ tinkerId = "1.0" /** * if keepDexApply is true, class in which dex refer to the old apk. * open this can reduce the dex diff file size. */ keepDexApply = false } dex { /** * optional,default 'jar' * only can be 'raw' or 'jar'. for raw, we would keep its original format * for jar, we would repack dexes with zip format. * if you want to support below 14, you must use jar * or you want to save rom or check quicker, you can use raw mode also */ dexMode = "jar" /** * necessary,default '[]' * what dexes in apk are expected to deal with tinkerPatch * it support * or ? pattern. */ pattern = ["classes*.dex", "assets/secondary-dex-?.jar"] /** * 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 * */ loader = [ //use sample, let BaseBuildInfo unchangeable with tinker "tinker.sample.android.app.BaseBuildInfo" ] } lib { /** * optional,default '[]' * what library in apk are expected to deal with tinkerPatch * it support * or ? pattern. * for library in assets, we would just recover them in the patch directory * you can get them in TinkerLoadResult with Tinker */ pattern = ["lib/armeabi/*.so"] } res { /** * optional,default '[]' * what resource in apk are expected to deal with tinkerPatch * it support * or ? pattern. * you must include all your resources in apk here, * otherwise, they won't repack in the new apk resources. */ pattern = ["res/*", "assets/*", "resources.arsc", "AndroidManifest.xml"] /** * optional,default '[]' * the resource file exclude patterns, ignore add, delete or modify resource change * it support * or ? pattern. * Warning, we can only use for files no relative with resources.arsc */ ignoreChange = ["assets/sample_meta.txt"] /** * default 100kb * for modify resource, if it is larger than 'largeModSize' * we would like to use bsdiff algorithm to reduce patch file size */ largeModSize = 100 } packageConfig { /** * optional,default 'TINKER_ID, TINKER_ID_VALUE' 'NEW_TINKER_ID, NEW_TINKER_ID_VALUE' * package meta file gen. path is assets/package_meta.txt in patch file * you can use securityCheck.getPackageProperties() in your ownPackageCheck method * or TinkerLoadResult.getPackageConfigByName * we will get the TINKER_ID from the old apk manifest for you automatic, * other config files (such as patchMessage below)is not necessary */ configField("patchMessage", "tinker is sample to use") /** * just a sample case, you can use such as sdkVersion, brand, channel... * you can parse it in the SamplePatchListener. * Then you can use patch conditional! */ configField("platform", "all") /** * patch version via packageConfig */ configField("patchVersion", "1.0") } //or you can add config filed outside, or get meta value from old apk //project.tinkerPatch.packageConfig.configField("test1", project.tinkerPatch.packageConfig.getMetaDataFromOldApk("Test")) //project.tinkerPatch.packageConfig.configField("test2", "sample") /** * if you don't use zipArtifact or path, we just use 7za to try */ sevenZip { /** * optional,default '7za' * the 7zip artifact path, it will use the right 7za with your platform */ zipArtifact = "com.tencent.mm:SevenZip:1.1.10" /** * optional,default '7za' * you can specify the 7za path yourself, it will overwrite the zipArtifact value */ // path = "/usr/local/bin/7za" } } List<String> flavors = new ArrayList<>(); project.android.productFlavors.each {flavor -> flavors.add(flavor.name) } boolean hasFlavors = flavors.size() > 0 /** * bak apk and 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}" def destPath = hasFlavors ? file("${bakPath}/${project.name}-${date}/${variant.flavorName}") : bakPath from variant.outputs.outputFile into destPath rename { String fileName -> fileName.replace("${fileNamePrefix}.apk", "${newFileNamePrefix}.apk") } from "${buildDir}/outputs/mapping/${variant.dirName}/mapping.txt" into destPath rename { String fileName -> fileName.replace("mapping.txt", "${newFileNamePrefix}-mapping.txt") } from "${buildDir}/intermediates/symbols/${variant.dirName}/R.txt" into destPath rename { String fileName -> fileName.replace("R.txt", "${newFileNamePrefix}-R.txt") } } } } } } project.afterEvaluate { //sample use for build all flavor for one time 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" } } } } }
关于各个参数信息大家可以参考接入指南
然后新建一个类继承TinkerApplication:
public class OneApplicationForTinker extends TinkerApplication { public OneApplicationForTinker() { super( //tinkerFlags, tinker支持的类型,dex,library,还是全部都支持! ShareConstants.TINKER_ENABLE_ALL, //ApplicationLike的实现类,只能传递字符串,不能使用class.getName() "com.juyao.tinkertest.MyApplication", //加载Tinker的主类名,对于特殊需求可能需要使用自己的加载类。需要注意的是: //这个类以及它使用的类都是不能被补丁修改的,并且我们需要将它们加到dex.loader[]中。 //一般来说,我们使用默认即可。 "com.tencent.tinker.loader.TinkerLoader", //由于合成过程中我们已经校验了各个文件的Md5,并将它们存放在/data/data/..目录中。 // 默认每次加载时我们并不会去校验tinker文件的Md5,但是你也可通过开启loadVerifyFlag强制每次加载时校验, // 但是这会带来一定的时间损耗。 false); } }
修改AndroidManifest.xml文件:
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.juyao.tinkertest"> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <application android:name=".OneApplicationForTinker" android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" 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> </application> </manifest>
(注意要新增文件读取权限)
然后新建一个Application主类MyApplication继承DefaultApplicationLike,并且实现onBaseContextAttached方法:
public class MyApplication extends DefaultApplicationLike { public MyApplication(Application application, int tinkerFlags, boolean tinkerLoadVerifyFlag, long applicationStartElapsedTime, long applicationStartMillisTime, Intent tinkerResultIntent, Resources[] resources, ClassLoader[] classLoader, AssetManager[] assetManager) { super(application, tinkerFlags, tinkerLoadVerifyFlag, applicationStartElapsedTime, applicationStartMillisTime, tinkerResultIntent, resources, classLoader, assetManager); } @Override public void onBaseContextAttached(Context base) { super.onBaseContextAttached(base); TinkerInstaller.install(this);//Tinker 初始化 } }
配置完如上信息,现在开始写代码。首先写一个如图的样式布局:
xml代码:
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/activity_main" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingLeft="@dimen/activity_horizontal_margin" android:paddingRight="@dimen/activity_horizontal_margin" android:paddingTop="@dimen/activity_vertical_margin" android:paddingBottom="@dimen/activity_vertical_margin" tools:context="com.juyao.tinkertest.MainActivity"> <Button android:text="显示名字" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="17dp" android:id="@+id/btn_show_name" android:layout_below="@+id/editText" android:layout_alignParentLeft="true" android:layout_alignParentStart="true"/> <Button android:text="加载Patch" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="11dp" android:id="@+id/btn_load_patch" android:layout_below="@+id/btn_show_name" android:layout_alignParentLeft="true" android:layout_alignParentStart="true"/> <EditText android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="textPersonName" android:text="" android:ems="10" android:id="@+id/editText" android:layout_alignParentTop="true" android:layout_alignParentLeft="true" android:layout_alignParentStart="true"/> </RelativeLayout>
MainActivity代码:
package com.juyao.tinkertest; import android.os.Environment; import android.support.v7.app.AppCompatActivity; import android.os.Bundle; import android.util.Log; import android.view.View; import android.widget.EditText; import android.widget.Toast; import com.tencent.tinker.lib.tinker.TinkerInstaller; import java.io.File; public class MainActivity extends AppCompatActivity { private static final String TAG = MainActivity.class.getSimpleName(); EditText etUserName; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); //Log.e(TAG,"i am on patch log"); //etUserName= (EditText) findViewById(R.id.editText); findViewById(R.id.btn_show_name).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { String name = etUserName.getText().toString(); Toast.makeText(getApplicationContext(),"输入的名字为:" + name,Toast.LENGTH_SHORT).show(); } }); findViewById(R.id.btn_load_patch).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { String patchPath = Environment.getExternalStorageDirectory().getAbsolutePath() + "/patch_signed_7zip.apk"; File file = new File(patchPath); if (file.exists()) { Log.v(TAG,"补丁文件存在"); TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(), patchPath); } else { Log.v(TAG,"补丁文件不存在"); } } }); } }
以上
这两行代码是注释掉的,所以必定运行起来必定会报空指针异常。
下面执行gradle assembleDebug 命令,执行完成后,会在app/build 路径下面会生成一个bakApk文件夹,里面有个app-debug-0105-09-44-52.apk文件,将这个apk文件安装到手机里运行,不出所料,点击“显示名字”按钮,应用发生了闪退,日志信息报空指针异常。
然后将刚才注释的两行代码取消注释,将app/build.gradle文件里的
这个文件名修改成刚刚生成的文件名,接着在命令行运行gradle tinkerPatchDebug命令,如果报
* What went wrong: Execution failed for task ':app:tinkerPatchDebug'. > old apk /Users/juyao/AndroidStudioProjects/TinkerTest/app/build/bakApk/app-debug-0104-16-57-35.apk is not exist, you must set the correct old apk value!
这个错误,那就是上述没有修改文件名。
执行完成后,在app/build/outputs下会生成一个tinkerPatch文件夹,将tinkerPatch/debug文件夹下的patch_signed_7zip.apk补丁文件放到手机根目录下(其实只要跟代码里的文件路径保持一致就行),然后点击“加载PATCH”按钮,这里有个坑,希望大家注意,Android6.0以上需要手动申请权限,不然会报如下错误:
Throwable:Tinker Exception:ShareSecurityCheck file /storage/emulated/0/patch_signed_7zip.apk, size 1959 verifyPatchMetaSignature fail
加载补丁成功后,日志会输出
DefaultTinkerResultService received a result: PatchResult: isSuccess:true rawPatchFilePath:/storage/emulated/0/patch_signed_7zip.apk costTime:49
这时候应用会重新运行,点击“显示名字按钮”,就能正常Toast消息了。补丁安装成功。
以上就是第一次接触Tinker的流程,适合第一次接触Tinker的阅读人群,关于一些细节,后面还会详细与大家交流,比如补丁加载成功失败的监听,大家也可以去看官方文档,么么哒~~今天就到这里,想要源码的话就可以联系邮箱juyao0909@gmail.com
相关文章推荐
- APP下载二维码微信扫一扫无法打开解决方案
- 微信分享JS接口失效说明及解决方案
- 微信域名检测解决方案
- iOS下微信语音播放之切换听筒和扬声器的方法解决方案
- 微信内置浏览器 长按识别二维码 的问题与解决方案
- 微信分享JS接口失效说明及解决方案
- iOS下微信语音播放之切换听筒和扬声器的方法解决方案
- IOS下平台下微信图片错乱问题及解决方案
- 微信内置浏览器不支持下载文件或应用解决方案——李帅醒博客
- 【微信小程序】下拉加载多次请求的解决方案,避免用户多次发起请求降低业务处理。
- iOS下微信语音播放之切换听筒和扬声器的方法解决方案
- 【腾讯Bugly干货分享】微信热补丁Tinker的实践演进之路
- 微信与SAP集成解决方案
- mac版微信web开发者工具(小程序开发工具)无法显示二维码 解决方案
- 关于微信sdk的注意事项与解决方案
- 微信对接ERP、CRM、OA、HR、SCM、PLM等管理系统解决方案
- 医院微信应用解决方案
- 微信小程序 同步异步解决方案