IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 移动开发 -> Android插件化原理及基础实现【hook篇】 -> 正文阅读

[移动开发]Android插件化原理及基础实现【hook篇】

插件化的理念

将应用分为多个模块,分出宿主与插件

用户安装宿主,动态加载插件

插件化的优点

按需加载、可插拔、动态更新

减小apk体积,解决方法是超过65535的问题

插件分开开发与编译,提高效率,降低耦合度

插件化的缺点

提升项目复杂度

插件化框架

特性dynamic-load-apkDynamicApkSmallDroidPluginVirtualAPK
作者任玉刚携程wequick360滴滴
四大组件支持只支持Activity只支持Activity只支持Activity全支持全支持
插件无需在清单文件预注册x
插件可以依赖宿主x
支持PendingIntentxxx
Android特性支持大部分大部分大部分几乎全部几乎全部
兼容性适配一般一般中等
插件构建部署AAPTGradle插件Gradle插件

插件的架构思路

Demo地址

如何加载插件?

通过Android的类加载机制,和几个ClassLoader分别的作用可知,想要加载插件,就需要将插件项目打成dex或apk,然后根据DexClassLoader指定插件路径,即可加载

apk是一个完整的Android包,其中包含了dex代码部分,res资源部分,AndroidManifest清单部分,作为插件时,其资源也可以被用到

dex是一个纯代码的包,有多个class文件组合而成

dex可作为热修复,apk可以作为完整的插件使用,这里讲的是插件的使用,就以apk为例

独立声明一个插件工程,在其中编写代码,然后编译成apk,其过程和正常apk工程一样

将apk放入手机存储,然后通过DexClassLoader加载,此时,可以调用没有使用的资源的类的方法

插件中的类

public class PluginTest {
    public static void doSomeThing(){
        Log.d("hahaha", "PluginTest doSomeThing ");
    }
}

拷贝插件到私有目录,并创建插件的类加载器

private boolean copyPlugin() {
  String pluginName = "plugin.apk";
  // 插件所在目录
  mPluginPath = mContext.getExternalFilesDir(null).getAbsolutePath() + File.separator + pluginName;
  // 最终拷贝到的私有目录
  String targetPath = mContext.getDir(PLUGIN_CACHE_DIR_NAME,Context.MODE_PRIVATE).getAbsolutePath();
  if(!new File(mPluginPath).exists()){
    // 如果插件不存在
    Log.e(TAG,"plugin not exists !!!");
    return false;
  }
  File targetFile = new File(targetPath);
  if(targetFile.exists()){
    // 如果目标目录已有插件,则删除
    targetFile.delete();
  }
  if(!targetFile.getParentFile().exists()){
    // 如果目标目录不存在,则创建
    targetFile.getParentFile().mkdirs();
  }
  try {
    // 拷贝插件到私有缓存目录
    FileInputStream in = new FileInputStream(mPluginPath);
    FileOutputStream out = new FileOutputStream(targetPath);
    FileUtil.copy(in, out);
  }catch (Exception e){
    Log.e(TAG,"plugin copy failed !!!");
    e.printStackTrace();
    return false;
  }
  // 创建解析插件的ClassLoader
  // PLUGIN_UNCOMPRESS_DIR 插件解压地址,但是在Android8之后就无用了,插件解压地址又系统特定
  mPluginClassloader = new DexClassLoader(targetPath,PLUGIN_UNCOMPRESS_DIR,null,mContext.getClassLoader());
  return true;
}

通过插件的DexClassLoader,执行插件中的方法

// 调用插件中的类方法
// 通过插件的DexClassLoader加载插件中的类
val pluginTestClass = PluginManager.get(this).pluginClassloader?.loadClass("com.ls.pluginapp.PluginTest")?:return
// 通过反射调用类静态方法
ReflectUtils.reflectStaticMethod(pluginTestClass,"doSomeThing")

不使用DexClassLoader加载插件类

如果只是反射几个类方法,可以使用插件的类加载器执行,但是一个插件中有很多类,而且还有组件,不可能全用插件的ClassLoader.loadClass

参考Android的类加载逻辑可知,ClassLoader的loadClass方法,加载类是在pathList属性中的dexElements数组中查找类的

根据这个,我们可以通过反射的方式,将插件DexClassLoader中的pathList.dexElements数组,复制到宿主APK的PathClassLoader的pathList.dexElements中去

这样,PathClassLoader在查找类的时候,也就可以找到插件中的类了,而不需要借助DexClassLoader去加载了

热修复的思路也是如此,将需要热修复的代码打成dex包,然后把dex包下载到本地,通过DexClassLoader解析,然后将DexClassLoader中的pathList.dexElements数组中的元素,复制到PathClassLoader中的pathList.dexElements数组的最前面,这样,PathClassLoader在寻找类的时候,会先找到最前面的类,也就是热修复过的类进行加载,而在后面的原有类就被忽略掉了

合并dexElements的方法

private boolean dexMerge(){
  if(mPluginClassloader == null){
    return false;
  }
  String pathList = "pathList";
  // 获取到宿主ClassLoader的pathList属性
  Object systemPathList = ReflectUtils.getFieldValue(mContext.getClassLoader(),pathList);
  // 获取到插件ClassLoader的pathList属性
  Object pluginPathList = ReflectUtils.getFieldValue(mPluginClassloader,pathList);
  String dexElements = "dexElements";
  // 获取到宿主pathList的dexElements属性
  Object systemDexElements = ReflectUtils.getFieldValue(systemPathList,dexElements);
  // 获取到插件pathList的dexElements属性
  Object pluginDexElements = ReflectUtils.getFieldValue(pluginPathList,dexElements);
  if(systemDexElements == null || pluginDexElements == null){
    // dexElements获取失败
    Log.e(TAG,"dexElements not found, systemDexElements = " + systemDexElements + ", pluginDexElements = " + pluginDexElements);
    return false;
  }
  // 合并宿主的dexElements和插件的dexElements为新的dexElements数组
  Object newElements = combineArray(pluginDexElements,systemDexElements);
  if(newElements == null){
    // dexElements合并失败
    Log.e(TAG,"dexMerge failed !!!");
    return false;
  }
  // 新的dexElements数组设置给宿主的pathList中的dexElements属性
  if(!ReflectUtils.setField(systemPathList,dexElements,newElements)){
    // 新的dexElements设置失败
    Log.e(TAG,"new dexElements set failed !!!");
    return false;
  }
  return true;
}

直接在PathClassLoader中调用插件方法

// 通过PathClassLoader加载插件中的类
val pluginTestClass = classLoader?.loadClass("com.ls.pluginapp.PluginTest")?:return
// 通过反射调用类静态方法
ReflectUtils.reflectStaticMethod(pluginTestClass,"doSomeThing")

如何启动组件?

Activity的启动时通过请求AMS检查通过之后,然后再启动并调用器生命周期

未在AMS中注册的Activity是无法通过检查的,也就是没有在AndroidManifest.xml中注册的Activity,无法启动

而插件中的组件是没有在宿主的AndroidManifest.xml中注册的,也就是无法正常启动组件

而根据Activity启动逻辑可以得出如下思路,来绕过AMS检查,从而启动Activity

启动思路

因为Activity在启动前会请求AMS检查,那么可以在AMS检查前将包含插件Activity的Intent替换为已经在宿主清单文件中注册过的Activity的Intent

待AMS检查完毕之后,在Activity真正启动之前,再将包含注册过的Activity的Intent替换会包含插件Activity的Intent

如此狸猫换太子,插件的Activity就得以正常启动了

第一次替换

根据AMS跨进程通讯源码可知,在启动Activity之前,会将Intent传给ActivityManager的一个静态属性ActivityManagerService单例对象的startActivity方法

那么通过动态代理将其单例对象的方法执行代理过来,然后在startActivity方法执行之前,将其Intent参数,替换为已经注册过的Activity的Intent,然后交给AMS去检查

在这里插入图片描述

第一次替换的版本适配

ActivityManager的代码在API26的时候改版过,所以,动态代理时,其属性名有所变动

通过源码对比可知,其改变了获取单例的静态方法与静态属性的名称,而类型没有改变,所以我们只需要将反射的名称改变即可

在这里插入图片描述

API29之后,启动Activity就不是调用ActivityManager了,而是独立出了一个ActivityTaskManager来管理Activity

在这里插入图片描述

private boolean hookAMS(){
  // 系统是9.0及以下,获取IActivityManager单例对象
  if(Build.VERSION.SDK_INT <= Build.VERSION_CODES.P){
    Log.e(TAG,"api <= 25,start hook AMS !!!");
    Object singleton = null;
    // 系统是7.1及以下,获取IActivityManager单例的类名是ActivityManagerNative,属性名是gDefault
    if(Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1){
      Class<?> clazz = ReflectUtils.getClass("android.app.ActivityManagerNative");
      Object gDefault = ReflectUtils.getStaticFieldValue(clazz,"gDefault");
      singleton = gDefault;
      if(singleton == null){
        // api <= 25,get gDefault failed
        Log.e(TAG,"api <= 25,get gDefault failed !!!");
        return false;
      }
    }
    // 系统是9.0及以下,获取IActivityManager单例的类名是ActivityManager,属性名是IActivityManagerSingleton
    else{
      Log.e(TAG,"api <= 28,start hook AMS !!!");
      Object IActivityManagerSingleton = ReflectUtils.getStaticFieldValue(ActivityManager.class,"IActivityManagerSingleton");
      singleton = IActivityManagerSingleton;
      if(singleton == null){
        // api <= 25,get IActivityManagerSingleton failed
        Log.e(TAG,"api <= 28,get IActivityManagerSingleton failed !!!");
        return false;
      }
    }
    // 获取单例对象的实例,也就是AMS
    Object AMSObject = ReflectUtils.getFieldValue(singleton,"mInstance");
    Class<?> IActivityManagerClazz = ReflectUtils.getClass("android.app.IActivityManager");
    // 对AMS对象进行动态代理,拦截startActivity方法,将Intent参数替换成宿主的Activity
    Object AMSProxy = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), new Class[]{IActivityManagerClazz}, (proxy, method, args) -> {
      if("startActivity".equals(method.getName())) {
        Log.d(TAG,"Proxy IActivityManager startActivity invoke...");
        for (int i = 0; i < args.length; i++) {
          if (args[i] instanceof Intent) {
            Intent intent = new Intent();
            intent.setClass(mContext, RegisteredActivity.class);
            intent.putExtra("actionIntent", (Intent) args[i]);
            args[i] = intent;
            Log.d(TAG,"replaced startActivity intent");
          }
        }
      }
      return method.invoke(AMSObject,args);
    });
    // 将动态代理实例设置给单例
    boolean success = ReflectUtils.setField(singleton,"mInstance",AMSProxy);
    if(!success){
      Log.e(TAG,"api <= 28,AMS hook failed !!!");
    }
    return success;
  }
  // 系统是12及以下,获取IActivityTaskManager单例对象,API29开始将单独提取出了ActivityTaskManager来管理Activity
  else{
    Log.e(TAG,"api <= 32,start hook A(T)MS !!!");
    // 获取ActivityTaskManager类的静态属性IActivityTaskManagerSingleton,是一个单例
    Class<?> clazz = ReflectUtils.getClass("android.app.ActivityTaskManager");
    Object IActivityTaskManagerSingleton = ReflectUtils.getStaticFieldValue(clazz,"IActivityTaskManagerSingleton");
    // 获取单例的实例,即A(T)MS对象
    Object AMSObject = ReflectUtils.getFieldValue(IActivityTaskManagerSingleton,"mInstance");
    Class<?> IActivityTaskManagerClazz = ReflectUtils.getClass("android.app.IActivityTaskManager");
    // 对A(T)MS对象进行动态代理,拦截startActivity方法,将Intent参数替换成宿主的Activity
    Object AMSProxy = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), new Class[]{IActivityTaskManagerClazz}, (proxy, method, args) -> {
      if("startActivity".equals(method.getName())) {
        Log.d(TAG,"Proxy IActivityTaskManager startActivity invoke...");
        for (int i = 0; i < args.length; i++) {
          if (args[i] instanceof Intent) {
            Intent intent = new Intent();
            intent.setClass(mContext, RegisteredActivity.class);
            intent.putExtra("actionIntent", (Intent) args[i]);
            args[i] = intent;
            Log.d(TAG,"replaced startActivity intent");
          }
        }
      }
      return method.invoke(AMSObject,args);
    });
    // 将动态代理实例设置给单例
    boolean success = ReflectUtils.setField(IActivityTaskManagerSingleton,"mInstance",AMSProxy);
    if(!success){
      Log.e(TAG,"api > 28,AMS hook failed !!!");
    }
    return success;
  }
}
第二次替换

通过AMS的第二次跨进程通讯可知,在AMS检查完成之后,是在Binder线程,需要通过Handler发送消息到UI线程去启动Activity,而Handler执行机制会先判断并执行成员属性mCallback

这里将Handler的mCallback赋值为自己写的Callback,在其中处理LAUNCHER_ACTIVITY消息,将Intent替换回包含插件Activity的Intent,让系统去启动

如此,一次插件Activity的启动就完成了

第二次替换的版本适配

ActicityThread的源码造API28是经过一次改变,所以其发送Handler消息的方式和消息ID都改变了

通过源码对比可知,其消息名称从LAUNCHER_ACTIVITY变成了EXECUTE_TRANSACTION,而且因为添加了事务的原因,其msg.obj不再是简单的ActivityClientRecord对象,而是ClientTransaction对象,而Intent就会放在ClientTransaction中的callbacks集合中的某一个Item中,而callbacks集合是一个ClientTransactionItem集合,其中的LauncherActivityItem是ClientTransaction的子类,也是启动Activity的Item,Intent就在其中

此时通过遍历ClientTransaction的callbacks,找到LauncherActivityItem,将其中的Intent替换回包含插件Activity的Intent,让系统去启动

在这里插入图片描述

在这里插入图片描述

private boolean hookHandler(){
  // 拦截AMS检查完成之后的消息,将启动Activity的消息拦截,将消息中的Intent替换会插件Activity
  // 获取ActivityThread类
  Class<?> activityThreadClazz = ReflectUtils.getClass("android.app.ActivityThread");
  // 获取ActivityThread的静态属性sCurrentActivityThread,也是它自己的实例
  Object activityThreadObject = ReflectUtils.getStaticFieldValue(activityThreadClazz,"sCurrentActivityThread");
  // 获取ActivityThread实例的mH属性,是一个Handler消息处理器
  Object mHObject = ReflectUtils.getFieldValue(activityThreadObject,"mH");
  // 设置Handler的实例属性mCallback为HookHmCallback,在其中进行消息拦截
  boolean success = ReflectUtils.setField(mHObject,"mCallback",new HookHmCallback());
  if(!success){
    Log.e(TAG,"api > 28,Handler hook failed !!!");
  }
  return success;
}

private class HookHmCallback implements Handler.Callback {
  private static final int LAUNCH_ACTIVITY         = 100;
  private static final int EXECUTE_TRANSACTION = 159;


  @Override
  public boolean handleMessage(@NonNull Message msg) {
    switch (msg.what){
      // API 21 ~ 27 启动Activity的消息是LAUNCH_ACTIVITY
      case LAUNCH_ACTIVITY:
      Log.d(TAG,"HookHmCallback handleMessage LAUNCH_ACTIVITY enter !!!");
      // 消息对象是ActivityClientRecord对象,其中包含Intent
      // 获取intent对象
      Object intentObject = ReflectUtils.getFieldValue(msg.obj,"intent");
      if(intentObject instanceof  Intent){
        Intent intent = (Intent) intentObject;
        // 将之前替换缓存下来的插件Intent替换回去
        Parcelable actionIntent = intent.getParcelableExtra("actionIntent");
        if(actionIntent != null){
          boolean success = ReflectUtils.setField(msg.obj,"intent",actionIntent);
          if(success){
            Log.d(TAG,"HookHmCallback handleMessage LAUNCH_ACTIVITY replaced !!!");
          }
        }
      }
      break;
      // API 28 ~ 32,添加了事务管理,启动Activity的消息是EXECUTE_TRANSACTION
      case EXECUTE_TRANSACTION:
      Log.d(TAG,"HookHmCallback handleMessage EXECUTE_TRANSACTION enter !!!");
      // 启动Activity之中EXECUTE_TRANSACTION其中一条消息,需要找到属于启动Activity的那条消息
      // 消息对象是ClientTransaction对象,其中有ClientTransactionItem列表
      // 启动Activity的Item是LaunchActivityItem,其中包含Intent
      // 获取mActivityCallbacks,Item列表对象
      Object mActivityCallbacksObject = ReflectUtils.getFieldValue(msg.obj,"mActivityCallbacks");
      if(mActivityCallbacksObject instanceof List){
        List mActivityCallbacks = (List) mActivityCallbacksObject;
        // 循环列表
        for (Object callbackItem : mActivityCallbacks) {
          // 找到LaunchActivityItem对象
          if(TextUtils.equals(callbackItem.getClass().getName(),"android.app.servertransaction.LaunchActivityItem")){
            // 获取LaunchActivityItem的Intent对象
            Object mIntentObject = ReflectUtils.getFieldValue(callbackItem,"mIntent");
            if(mIntentObject instanceof Intent){
              Intent mIntent = (Intent) mIntentObject;
              // 将之前替换缓存下来的插件Intent替换回去
              Parcelable actionIntent = mIntent.getParcelableExtra("actionIntent");
              if(actionIntent != null){
                boolean success = ReflectUtils.setField(callbackItem,"mIntent",actionIntent);
                if(success){
                  Log.d(TAG,"HookHmCallback handleMessage EXECUTE_TRANSACTION replaced !!!");
                }
              }
            }
          }
        }
      }
      break;
    }
    return false;
  }
}

如何加载插件资源?

资源的加载是通过Resource对象完成的,获取Resource对象可以直接使用Context的getResource方法即可,而如此获取的Resource对象是宿主的Resource,也只能加载宿主apk中的资源

通过资源加载流程可知,Resource对象的创建传入了AssetManager对象,和一些手机配置,手机配置无可厚非,直接使用Context获取就好了

而AssetManager是管理资源的类,其中有个方法addAssetPath,是添加资源路径的,那么想要加载插件资源,只需要创建一个新的AssetManager对象,将插件的资源路径传入addAssetPath方法,然后根据新的AssetManager对象创建新的Resource对象,作为插件的资源加载入口,即可完成资源的加载

而因为插件有时也可以独立运行,所以其Resource对象不能只用新创建的,需要根据场景使用系统的Resource或新创建的Resource

其实,在宿主的Application中新创建一个Resource,作为插件的Resource,然后在插件中使用时使用getApplication().getResource(),作为资源加载入口

那么此时,如果插件独立运行,其Application就不是宿主的Application,获取到的Resource对象自然就是系统的Resource

而作为插件运行时,其Application就是宿主的Application,其Resource是通过插件路径新创建的Resource

如此,在不同场景使用都不会有问题了

在这里插入图片描述

创建插件的Resources对象

private boolean createPluginResource() {
  // 创建属于插件的Resources对象
  Log.e(TAG, "createPluginResource: enter !!!");
  try {
    // 创建Resources所需的AssetManager对象
    AssetManager pluginAssetManager = AssetManager.class.newInstance();
    // 设置AssetManager的资源路径为插件路径
    boolean success = ReflectUtils.reflectMethod(pluginAssetManager,"addAssetPath",mPluginPath);
    if(!success){
      Log.e(TAG, "createPluginResource: addAssetPath failed !!!");
    }
    // 创建Resources对象
    mPluginResources = new Resources(pluginAssetManager,mContext.getResources().getDisplayMetrics(),mContext.getResources().getConfiguration());
    return success;
  } catch (Exception e) {
    Log.e(TAG, "createPluginResource: AssetManager instantiation failed !!!");
    e.printStackTrace();
    return false;
  }
}

重写宿主Application的getResources方法

@Override
public Resources getResources() {
  // 重写宿主Application的getResources方法
  // 如果存在插件的Resources对象,便返回插件的Resources
  // 否则返回宿主自己的Resources
  Resources pluginResources = PluginManager.get(getApplicationContext()).getPluginResources();
  if(pluginResources != null){
    return pluginResources;
  }
  return super.getResources();
}

插件中写BasePluginActivity,所有的Activity都继承它,然后重写它的getResources方法

/**
 * 插件的全部Activity都继承于这个类
 */
abstract class BasePluginActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }

    override fun getResources(): Resources {
        // 因为插件的全部Activity都继承于这个类,所以当Activity需要加载资源的时候,会访问这个getResources方法
        // 如果获取application的resources不为空
        //    如果当前app以插件形式在宿主中运行,那得到的便是宿主Application中的Resources对象
        //    又因为宿主的Application返回的是插件的Resources对象,所以最终加载的仍然是插件的资源
        //    如果当前app独立运行,那么得到的便是是自身的Application,那么返回的将是自身的Resources对象
        // 否则返回自身的Resources对象
        val pluginResources = application?.resources
        if(pluginResources != null){
            return pluginResources
        }
        return super.getResources()
    }
}

Android类加载器

在这里插入图片描述

BootClassLoader:系统启动时用于加载系统常用类,ClassLoader内部类

PathClassLoader:加载系统类和已安装的应用程序类

DexClassLoader:在未安装的情况下加载dex文件及包含dex的apk或jar,热修复和插件化的基础

双亲委托机制

对于类加载的过程,使用到了双亲委托机制

  • 可以很好地避免重复加载
  • 可以提高类使用的安全性,将不同的类交给不同的ClassLoader

在这里插入图片描述

类加载逻辑

类的加载首先是通过双亲委托机制找到可以加载类的ClassLoader

然后ClassLoader会先找缓存,如果没有,就会调用DexPathList类型的dexPathList属性的findClass方法

而DexPathList的findClass方法,实质上就是在一个Element[] dexElement数组中查找对应的类,然后返回

Activity启动逻辑

两次跨进程访问

Activity的启动,考虑到安全性的问题,需要请求系统的AMS(ActivityManagerService)进行检查,如果传入的Activity没有在apk的清单文件中注册过,那么apk安装之后,AMS便无法解析到其Activity的信息,在检查时,自然就是无法通过的

因为AMS是系统进程,其他apk进程想要跨进程访问,需要通过Binder机制,也就是跨进程内存拷贝机制,而AMS检查完成之后需要通知apk进程,也是跨进程通讯,也需要用到Binder机制,所以是两次跨进程通讯

第一次,检查Activity跨进程

第一次,启动Activity,通过startActivity方法,其方法最终调到ActivityManager中,然后根据其中的AMS静态属性,访问AMS进程,并调用器startActivity方法
在这里插入图片描述

第二次,传回Intent跨进程

第二次,在AMS检查Activity完成之后,想要通知apk,就需要通过Binder,而在Binder线程中无法访问UI,所以会通过ActivityThread中的mH属性,也就是Handler对象发送消息,回调到apk的UI线程,然后去启动Activity,并调用其生命周期

在API28是,系统源码经过了改版,添加了事务,在代码层面,其调用复杂度成倍正价,不过其原理是万变不离其宗

API28以前
在这里插入图片描述

API28及以后
在这里插入图片描述

资源加载过程

在Activity启动过程中,ActivityThread在启动Activity之前,会为其创建对应的Context对象,而Context中自然也就带有Resource对象,Resource对象的创建,需要AssetManager作为参数,AssetManager是管理apk资源的类,其中有addAssetPath方法可以添加资源所在路径

AssetManger根据资源路径创建完成,Resource根据AssetManager创建完成,Context也就创建完成,Activity正式启动,调用onCreate方法
在这里插入图片描述

面试题

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

  移动开发 最新文章
Vue3装载axios和element-ui
android adb cmd
【xcode】Xcode常用快捷键与技巧
Android开发中的线程池使用
Java 和 Android 的 Base64
Android 测试文字编码格式
微信小程序支付
安卓权限记录
知乎之自动养号
【Android Jetpack】DataStore
上一篇文章      下一篇文章      查看所有文章
加:2022-06-04 00:03:50  更:2022-06-04 00:03:52 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/25 1:39:53-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码