文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

Android微信Tinker热更新详细使用

2022-06-06 04:39

关注

先看一下效果图

这里写图片描述

Tinker已知问题

由于原理与系统限制,Tinker有以下已知问题:

Tinker不支持修改AndroidManifest.xml,Tinker不支持新增四大组件; 由于Google Play的开发者条款限制,不建议在GP渠道动态更新代码; 在Android N上,补丁对应用启动时间有轻微的影响; 不支持部分三星android-21机型,加载补丁时会主动抛出”TinkerRuntimeException:checkDexInstall failed”; 由于各个厂商的加固实现并不一致,在1.7.6以及之后的版本,tinker不再支持加固的动态更新; 对于资源替换,不支持修改remoteView。例如transition动画,notification icon以及桌面图标。

1.首先在项目的build中,集成tinker插件 ,如下所示(目前最新版是1.7.6)

先看结构图,只有几个类而已:

这里写图片描述

项目中的build集成


buildscript {
 repositories {
 jcenter()
 }
 dependencies {
 classpath 'com.android.tools.build:gradle:2.2.3'
 classpath ('com.tencent.tinker:tinker-patch-gradle-plugin:1.7.6')
 // NOTE: Do not place your application dependencies here; they belong
 // in the individual module build.gradle files
 }
}
allprojects {
 repositories {
 jcenter()
 }
}
task clean(type: Delete) {
 delete rootProject.buildDir
}

1.再将app的build中的关联属性添加进去,这些属性都是经过测试过的,都有注释显示,如果自己需要其他属性,可以自己去github上查看并集成,文章末尾会送上地址,ps:官方的集成特别麻烦,有时候一整天都有可能搞不定,根据自己的需求和情况来添加,末尾会送上demo


apply plugin: 'com.android.application'
def javaVersion = JavaVersion.VERSION_1_7
android {
 compileSdkVersion 23
 buildToolsVersion "23.0.2"
 compileOptions {
 sourceCompatibility javaVersion
 targetCompatibility javaVersion
 }
 //recommend
 dexOptions {
 jumboMode = true
 }
 defaultConfig {
 applicationId "com.tinker.demo.tinkerdemo"
 minSdkVersion 15
 targetSdkVersion 22
 versionCode 1
 versionName "1.0"
 testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
 buildConfigField "String", "MESSAGE", "\"I am the base apk\""
 buildConfigField "String", "TINKER_ID", "\"${getTinkerIdValue()}\""
 buildConfigField "String", "PLATFORM", "\"all\""
 }
 signingConfigs {
 release {
  try {
  storeFile file("./keystore/release.keystore")
  storePassword "testres"
  keyAlias "testres"
  keyPassword "testres"
  } catch (ex) {
  throw new InvalidUserDataException(ex.toString())
  }
 }
 debug {
  storeFile file("./keystore/debug.keystore")
 }
 }
 buildTypes {
 release {
  minifyEnabled true
  signingConfig signingConfigs.release
  proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
 }
 debug {
  debuggable true
  minifyEnabled false
  signingConfig signingConfigs.debug
 }
 }
 sourceSets {
 main {
  jniLibs.srcDirs = ['libs']
 }
 }
}
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'
 })
 compile "com.android.support:appcompat-v7:23.1.1"
 testCompile 'junit:junit:4.12'
 compile("com.tencent.tinker:tinker-android-lib:${TINKER_VERSION}") { changing = true }
 provided("com.tencent.tinker:tinker-android-anno:${TINKER_VERSION}") { changing = true }
 compile "com.android.support:multidex:1.0.1"
}
def gitSha() {
 try {
 // String gitRev = 'git rev-parse --short HEAD'.execute(null, project.rootDir).text.trim()
 String gitRev = "1008611"
 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 bakPath = file("${buildDir}/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-0113-14-01-29.apk"
 //proguard mapping file to build patch apk
 tinkerApplyMappingPath = "${bakPath}/app-debug-1018-17-32-47-mapping.txt"
 //resource R.txt to build patch apk, must input if there is resource changed
 tinkerApplyResourcePath = "${bakPath}/app-debug-0113-14-01-29-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 {
 
 oldApk = getOldApkPath()
 
 ignoreWarning = false
 
 useSign = true
 
 tinkerEnable = buildWithTinker()
 
 buildConfig {
  
  applyMapping = getApplyMappingPath()
  
  applyResourceMapping = getApplyResourceMappingPath()
  
  tinkerId = getTinkerIdValue()
  
  keepDexApply = false
 }
 dex {
  
  dexMode = "jar"
  
  pattern = ["classes*.dex",
   "assets/secondary-dex-?.jar"]
  
  loader = [
   //use sample, let BaseBuildInfo unchangeable with tinker
   "tinker.sample.android.app.BaseBuildInfo"
  ]
 }
 lib {
  
  pattern = ["lib/armeabi
  pattern = ["res
  ignoreChange = ["assets/sample_meta.txt"]
  
  largeModSize = 100
 }
 packageConfig {
  
  configField("patchMessage", "tinker is sample to use")
  
  configField("platform", "all")
  
  configField("patchVersion", "1.0")
 }
 //或者您可以添加外部的配置文件,或从旧apk获取元值
 //project.tinkerPatch.packageConfig.configField("test1", project.tinkerPatch.packageConfig.getMetaDataFromOldApk("Test"))
 //project.tinkerPatch.packageConfig.configField("test2", "sample")
 
 sevenZip {
  
  zipArtifact = "com.tencent.mm:SevenZip:1.1.10"
  
// path = "/usr/local/bin/7za"
 }
 }
 List<String> flavors = new ArrayList<>();
 project.android.productFlavors.each {flavor ->
 flavors.add(flavor.name)
 }
 boolean hasFlavors = flavors.size() > 0
 
 android.applicationVariants.all { variant ->
 
 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"
   }
  }
  }
 }
 }
}

3.在清单文件中集成application和服务 ,name的application必须是.AMSKY,如果你添加不进去,或者是红色的话,请先build一下,如果你已经有了自己的application,后面我会说怎么来集成,Service中做的操作是在你加载成功热更新插件后,会提示你更新成功,并且这里做了锁屏操作就会加载热更新插件,继续往下看。


<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
 package="com.tinker.demo.tinkerdemo">
 <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
 <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
 <application
 android:allowBackup="true"
 android:icon="@mipmap/ic_launcher"
 android:label="@string/app_name"
 android:supportsRtl="true"
 android:name=".AMSKY"
 android:theme="@style/AppTheme">
 <service
  android:name=".service.SampleResultService"
  android:exported="false"/>
 <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>

4.到这里就已经基本集成的差不多了,剩下的就是代码里面的集成,首先是application,这里主要说如果是自已已经存在的application的时候改怎么操作 ,这个applicaiton可以说就是自己的一个application,只不过写法,要这样去写,可以在onCreate中做自己的一些操作,只不过清单文件中,要写AMSKY


@SuppressWarnings("unused")
@DefaultLifeCycle(application = "com.tinker.demo.tinkerdemo.AMSKY",
   flags = ShareConstants.TINKER_ENABLE_ALL,
   loadVerifyFlag = false)
public class SampleApplicationLike extends DefaultApplicationLike {
 private static final String TAG = "Tinker.SampleApplicationLike";
public SampleApplicationLike(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);
 }
 
 @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
 @Override
 public void onBaseContextAttached(Context base) {
 super.onBaseContextAttached(base);
 //MultiDex必须在Tinker初始化之前
 MultiDex.install(base);
 //这里就是初始化Tinker
 TinkerInstaller.install(this,new DefaultLoadReporter(getApplication()),new DefaultPatchReporter(getApplication()),
 new DefaultPatchListener(getApplication()),SampleResultService.class,new UpgradePatch());
 Tinker tinker = Tinker.with(getApplication());
 //这个只是一个Toast提示
 Toast.makeText(
 getApplication(),"没鸟用,就是Toast提示而已", Toast.LENGTH_SHORT).show();
 }
 @Override
 public void onCreate() {
 super.onCreate();
 //这里可以做自己的操作
 }
 @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
 public void registerActivityLifecycleCallbacks(Application.ActivityLifecycleCallbacks callback) {
 getApplication().registerActivityLifecycleCallbacks(callback);
 }
}

5.这里就是在MainActivity中来加载热更新文件,在点击加载的时候,就直接锁屏加载(不要删除service),当然退出app,下次进来也是可以加载的吗,这里加载补丁插件的话,路径可以自己设置,我是放在根目录的debug文件夹当中的,并且我的补丁插件名字叫patch,可以自行更改。


public class MainActivity extends AppCompatActivity {
 @Override
 protected void onCreate(Bundle savedInstanceState) {
 super.onCreate(savedInstanceState);
 setContentView(R.layout.activity_main);
 }
 
 public void loadPatch(View v) {
 TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(), "/sdcard/debug/patch.apk");
 }
 
 public void killApp(View v) {
 ShareTinkerInternals.killAllOtherProcess(getApplicationContext());
 android.os.Process.killProcess(android.os.Process.myPid());
 }
 @Override
 protected void onResume() {
 super.onResume();
 Utils.setBackground(false);
 }
 @Override
 protected void onPause() {
 super.onPause();
 Utils.setBackground(true);
 }
}

6.Service文件


public class SampleResultService extends DefaultTinkerResultService {
 private static final String TAG = "Tinker.SampleResultService";
 @Override
 public void onPatchResult(final PatchResult result) {
 if (result == null) {
  TinkerLog.e(TAG, "SampleResultService received null result!!!!");
  return;
 }
 TinkerLog.i(TAG, "SampleResultService receive result: %s", result.toString());
 //first, we want to kill the recover process
 TinkerServiceInternals.killTinkerPatchServiceProcess(getApplicationContext());
 Handler handler = new Handler(Looper.getMainLooper());
 handler.post(new Runnable() {
  @Override
  public void run() {
  if (result.isSuccess) {
   Toast.makeText(getApplicationContext(), "patch success, please restart process", Toast.LENGTH_LONG).show();
  } else {
   Toast.makeText(getApplicationContext(), "patch fail, please check reason", Toast.LENGTH_LONG).show();
  }
  }
 });
 // is success and newPatch, it is nice to delete the raw file, and restart at once
 // for old patch, you can't delete the patch file
 if (result.isSuccess) {
  File rawFile = new File(result.rawPatchFilePath);
  if (rawFile.exists()) {
  TinkerLog.i(TAG, "save delete raw patch file");
  SharePatchFileUtil.safeDeleteFile(rawFile);
  }
  //not like TinkerResultService, I want to restart just when I am at background!
  //if you have not install tinker this moment, you can use TinkerApplicationHelper api
  if (checkIfNeedKill(result)) {
  if (Utils.isBackground()) {
   TinkerLog.i(TAG, "it is in background, just restart process");
   restartProcess();
  } else {
   //we can wait process at background, such as onAppBackground
   //or we can restart when the screen off
   TinkerLog.i(TAG, "tinker wait screen to restart process");
   new ScreenState(getApplicationContext(), new ScreenState.IOnScreenOff() {
   @Override
   public void onScreenOff() {
    restartProcess();
   }
   });
  }
  } else {
  TinkerLog.i(TAG, "I have already install the newly patch version!");
  }
 }
 }
 
 private void restartProcess() {
 TinkerLog.i(TAG, "app is background now, i can kill quietly");
 //you can send service or broadcast intent to restart your process
 android.os.Process.killProcess(android.os.Process.myPid());
 }
 static class ScreenState {
 interface IOnScreenOff {
  void onScreenOff();
 }
 ScreenState(Context context, final IOnScreenOff onScreenOffInterface) {
  IntentFilter filter = new IntentFilter();
  filter.addAction(Intent.ACTION_SCREEN_OFF);
  context.registerReceiver(new BroadcastReceiver() {
  @Override
  public void onReceive(Context context, Intent in) {
   String action = in == null ? "" : in.getAction();
   TinkerLog.i(TAG, "ScreenReceiver action [%s] ", action);
   if (Intent.ACTION_SCREEN_OFF.equals(action)) {
   context.unregisterReceiver(this);
   if (onScreenOffInterface != null) {
    onScreenOffInterface.onScreenOff();
   }
   }
  }
  }, filter);
 }
 }
}

7.Utils文件


public class Utils {
 
 public static final int ERROR_PATCH_GOOGLEPLAY_CHANNEL = -5;
 public static final int ERROR_PATCH_ROM_SPACE  = -6;
 public static final int ERROR_PATCH_MEMORY_LIMIT  = -7;
 public static final int ERROR_PATCH_ALREADY_APPLY  = -8;
 public static final int ERROR_PATCH_CRASH_LIMIT  = -9;
 public static final int ERROR_PATCH_RETRY_COUNT_LIMIT = -10;
 public static final int ERROR_PATCH_CONDITION_NOT_SATISFIED = -11;
 public static final String PLATFORM = "platform";
 public static final int MIN_MEMORY_HEAP_SIZE = 45;
 private static boolean background = false;
 public static boolean isGooglePlay() {
 return false;
 }
 public static boolean isBackground() {
 return background;
 }
 public static void setBackground(boolean back) {
 background = back;
 }
 public static int checkForPatchRecover(long roomSize, int maxMemory) {
 if (Utils.isGooglePlay()) {
  return Utils.ERROR_PATCH_GOOGLEPLAY_CHANNEL;
 }
 if (maxMemory < MIN_MEMORY_HEAP_SIZE) {
  return Utils.ERROR_PATCH_MEMORY_LIMIT;
 }
 //or you can mention user to clean their rom space!
 if (!checkRomSpaceEnough(roomSize)) {
  return Utils.ERROR_PATCH_ROM_SPACE;
 }
 return ShareConstants.ERROR_PATCH_OK;
 }
 public static boolean isXposedExists(Throwable thr) {
 StackTraceElement[] stackTraces = thr.getStackTrace();
 for (StackTraceElement stackTrace : stackTraces) {
  final String clazzName = stackTrace.getClassName();
  if (clazzName != null && clazzName.contains("de.robv.android.xposed.XposedBridge")) {
  return true;
  }
 }
 return false;
 }
 @Deprecated
 public static boolean checkRomSpaceEnough(long limitSize) {
 long allSize;
 long availableSize = 0;
 try {
  File data = Environment.getDataDirectory();
  StatFs sf = new StatFs(data.getPath());
  availableSize = (long) sf.getAvailableBlocks() * (long) sf.getBlockSize();
  allSize = (long) sf.getBlockCount() * (long) sf.getBlockSize();
 } catch (Exception e) {
  allSize = 0;
 }
 if (allSize != 0 && availableSize > limitSize) {
  return true;
 }
 return false;
 }
 public static String getExceptionCauseString(final Throwable ex) {
 final ByteArrayOutputStream bos = new ByteArrayOutputStream();
 final PrintStream ps = new PrintStream(bos);
 try {
  // print directly
  Throwable t = ex;
  while (t.getCause() != null) {
  t = t.getCause();
  }
  t.printStackTrace(ps);
  return toVisualString(bos.toString());
 } finally {
  try {
  bos.close();
  } catch (IOException e) {
  e.printStackTrace();
  }
 }
 }
 private static String toVisualString(String src) {
 boolean cutFlg = false;
 if (null == src) {
  return null;
 }
 char[] chr = src.toCharArray();
 if (null == chr) {
  return null;
 }
 int i = 0;
 for (; i < chr.length; i++) {
  if (chr[i] > 127) {
  chr[i] = 0;
  cutFlg = true;
  break;
  }
 }
 if (cutFlg) {
  return new String(chr, 0, i);
 } else {
  return src;
 }
 }
}

到这里就已经集成完毕,下面来说下使用的方法

这是有bug的版本,我们测试就使用assembleDebug来测试 ,注意没点击assembleDebug之前,build文件夹里面是没有bakApk文件夹的

这里写图片描述这里写图片描述

2.点击assembleDebug之后会出现bakApk这个文件夹,里面就有apk文件,如果失败,记得clean一下,然后build一下

这里写图片描述

3.接下来在build文件夹里面,更改ext中的属性,将bakApk中生成的apk文件和R文件复制到ext这里,如果你打的release包有mapping的话同样复制到这里,我们这里是debug测试,所以没有mapping文件

这里写图片描述

4.下面就修改我们需要更新,或者更改的bug,我这里是添加一张图片,并且更改显示

这是有bug的版本,我还没添加图片,更改

这里写图片描述

这里我添加了一张aa的图片,并且更改了

这里写图片描述

5.接下来我们运行tinker下面的tinkerPatchDebug,来生成补丁包,这个补丁包在outputs下面

这里写图片描述

点击完成后,就会生成tinkerPatch文件夹

这里写图片描述

将tinkerPatch文件夹下面的patch_signed_7zip.apk文件,粘贴出来,改成你的MainActivity中加载的文件名字,我这里叫patch,然后点击加载没加载之前

这里写图片描述

加载之后,锁频,解锁 ,补丁已经加载出来了,并且文件夹中的补丁已经不在了,因为它和老apk合并了

这里写图片描述

注意

签名文件的话 在build的signingConfigs中设置,以及左侧的kestore文件夹中设置 ,如下图
这里写图片描述
这里写图片描述

项目github地址:TinkerDemo

Tinker原项目地址:https://github.com/Tencent/tinker
Tinker使用指南:https://github.com/Tencent/tinker/wiki
Tinker一键集成(这个简单,但是不能从自己服务器上下载补丁,不需配置Tinker自己的后台,有部分局限性,自行选择):https://github.com/TinkerPatch/tinkerpatch-sdk/blob/master/docs/tinkerpatch-android-sdk.md
Tinker一键集成后台:http://www.tinkerpatch.com/

更多精彩内容请点击《Android微信开发教程汇总》,《java微信开发教程汇总》欢迎大家学习阅读。

您可能感兴趣的文章:微信Android热更新Tinker使用详解(星空武哥)Android热更新开源项目Tinker集成实践总结Android热修复Tinker接入及源码解读


阅读原文内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-移动开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯