• @param context
  • @param pluginName 插件名
    */
    public static void copyApk(Context context, String pluginName) {
    DePluginSP sp = DePluginSP.getInstance(context);
    //获取插件apk保存路径
    String filePath = sp.getString(Constants.COPY_FILE_PATH, “”);
    if (TextUtils.isEmpty(filePath)) {
    //如果插件apk保存路径为空,说明没有copy插件apk到对应目录成功
    File saveApkFile = context.getFileStreamPath(pluginName);
    if (null == saveApkFile) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
    saveApkFile = context.getDataDir();
    filePath = saveApkFile.getAbsolutePath() + pluginName;
    } else {
    filePath = “data/user/0/” + context.getPackageName() + “/” + pluginName;
    }
    } else {
    filePath = saveApkFile.getAbsolutePath();
    }
    boolean result = extractAssets(context, filePath, pluginName);
    if (result) {
    sp.setString(Constants.COPY_FILE_PATH, filePath);
    }
    Log.i(TAG, "copy " + result);
    } else {
    //如果插件apk保存路径不为空,并且本地存在了apk则不在进行二次copy,否则可能已经被删除则重新复制一份到对应到目录下
    //当然在实际到开发中这里到情况会复杂的多,比如与服务器插件版本进行对比判断是否需要重新下载等
    File file = new File(filePath);
    if (file.exists()) {
    Log.i(TAG, “had copy apk before,so no need copy again”);
    } else {
    Log.i(TAG, “althogh save apk file path success,but file not exists”);
    extractAssets(context, filePath, pluginName);
    }
    }
    }
上述方法就是判断是否需要复制插件apk到对应的目录下,接下来就是copy部分了。因为代码量不到逻辑也很简单,就直接看代码了。

public static boolean extractAssets(Context context, String filePath, String pluginName) {
AssetManager assetManager = context.getAssets();

FileOutputStream fileOutputStream = null;
InputStream inputStream = null;
try {Log.i(TAG, "save apk file path is " + filePath);fileOutputStream = new FileOutputStream(filePath);//获取assets目录下的插件apk输入流inputStream = assetManager.open(pluginName);byte[] bytes = new byte[1024];int length = -1;//将apk文件复制到对应到文件目录下while ((length = inputStream.read(bytes)) != -1) {fileOutputStream.write(bytes, 0, length);}fileOutputStream.flush();return true;
} catch (Exception e) {Log.e(TAG, "copy file failed " + e.getMessage());
} finally {try {if (null != inputStream) {inputStream.close();}if (null != fileOutputStream) {fileOutputStream.close();}} catch (Exception e) {Log.i(TAG, "extractAssets: " + e.getMessage());}
}
return false;

}


### 2.2 ActivityManager的Hook这一块儿的hook,简单来说就是通过动态代理的方式将ActivityManager.getService所获取到的Binder代理对象进行替换,那么我们就能够对诸如start Activity等方法进行入侵了。相关代码实现如下。[RefInvoke.java]( )类可到github上查看。

public static void hookAMN() {
try {
//通过反射获取到ActivityManager的class对象
Class<?> mActivityManagerCls = RefInvoke.getClass(“android.app.ActivityManager”);
//首先通过反射获取到ActivityManager类中的单例对象IActivityManagerSingleton
//然后通过反射获取到对象对象IActivityManagerSingleton的值
Object mIActivityManagerSingletonObj = RefInvoke.getStaticFieldValue(RefInvoke.getField(mActivityManagerCls, “IActivityManagerSingleton”), mActivityManagerCls);
//获取到ActivityManager与AMS的Binder通信接口IActivityManager的class对象,用于后续生成对应的代理对象
Class<?> mIActivityManagerCls = RefInvoke.getClass(“android.app.IActivityManager”);
if (null != mIActivityManagerSingletonObj) {
//因为上述的单例对象是Singleton实现类,所以通过反射首先获取到该类中的mInstance属性
Field mInstanceField = RefInvoke.getField(“android.util.Singleton”, “mInstance”);
//然后通过反射获取到上述单例对象中的mInstance属性对应的值
Object mInstance = RefInvoke.getFieldValue(mInstanceField, mIActivityManagerSingletonObj);
//根据上述提供的接口以及当前的ClassLoader生成代理对象
Object proxy = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), new Class[]{mIActivityManagerCls}, new AMSHookHelperInvocationHandler(mInstance));
//将ActivityManager.getService获取到的单例对象替换成代理对象
RefInvoke.setFieldValue(mInstanceField, mIActivityManagerSingletonObj, proxy);
} else {
Log.i(TAG, “IActivityManagerSingleton not exists”);
}
} catch (Exception e) {
Log.i(TAG, "hook ATM failed " + e);
}
}

接下来就是实现InvocationHandler接口对startActivity方法进行入侵了,相关代码如下。

public class AMSHookHelperInvocationHandler implements InvocationHandler {
private static final String TAG = Constants.TAG + “AMSHookHandler”;

//被代理对象
private Object mBase;public AMSHookHelperInvocationHandler(Object base) {mBase = base;
}@Override
public Object invoke(Object o, Method method, Object[] objects) throws Throwable {//劫持startActivity方法对上层应用真正要启动的Activity进行替换if (TextUtils.equals(method.getName(), "startActivity")) {Log.i(TAG, "replace start up activity");int index = -1;//获取上层应用传递过来的Intent对象for (int i = 0; i < objects.length; i++) {if (objects[i] instanceof Intent) {index = i;break;}}if (-1 == index) {Log.i(TAG, "not found intent in params");return method.invoke(mBase, objects);}//这就是上层应用所需要启动的插件Activity对应的Intent了Intent realIntent = (Intent) objects[index];//根据宿主中预先声明的Activity生成对应的Intent对象用于替换上层应用传递过来的插件Activity相关的Intent对象,以达到欺骗AMS的目的Intent replacedStartUpIntent = realIntent.getParcelableExtra(Constants.REPLACED_START_UP_INTENT);if (null != replacedStartUpIntent) {Log.i(TAG, "origin intent is " + realIntent);realIntent.putExtra(Constants.REPLACED_START_UP_INTENT,"");replacedStartUpIntent.putExtra(Constants.START_UP_INTENT, realIntent);objects[index] = replacedStartUpIntent;Log.i(TAG, "replaced start up intent is " + replacedStartUpIntent);} else {Log.i(TAG, "replaced intent activity is null");}}//继续通过Binder的方式调用到AMSreturn method.invoke(mBase, objects);
}

}

有了上述对ActivityManager的hook过程,接下来我们就可以直接在应用中启动插件Activity了,使用方式如下。

public void click(View view) {
int id = view.getId();
switch (id) {
case R.id.plugin:
Intent intent = new Intent();
ComponentName componentName = new ComponentName(getPackageName(), “com.xx.xx.pluginActivity”);
intent.setComponent(componentName);
intent.putExtra(Constants.REPLACED_START_UP_INTENT, createStartUpIntent());

        startActivity(intent);break;
}

}
//使用宿主中预先声明好的Activity构造Intent对象用于欺骗AMS,后续统称为中转页面
private Intent createStartUpIntent() {
Intent startUpIntent = new Intent();
ComponentName componentName = new ComponentName(DePluginApplication.getContext(), StandardStubActivity.class.getName());
startUpIntent.setComponent(componentName);
startUpIntent.putExtra(Constants.DEX_PATH, DePluginSP.getInstance(DePluginApplication.getContext()).getString(Constants.COPY_FILE_PATH, “”));
return startUpIntent;
}

虽然我们能够在宿主中直接去启动插件中的Activity并且不会报出ActivityNotFound异常了,但是最后会发现启动的Activity并不是插件中的Activity,而是我们的中转页面StandardStubActivity。因此为了能够实现最终启动Activity是插件中的Activity,我们还需要对ActivityThread中中的各个对象进行hook。### 2.3 ActivityThread中的hook这一块儿所涉及的流程就稍微复杂一点了,因此代码量也稍微多一点。所以,建议大家有时间多可以去瞅瞅Activity启动流程源码分析相关的文章。#### 2.3.1 mH的hook对于ActivityThread中mH属性,如果采用生成Handler对象直接通过反射的方式去替换,最终系统会无情的给你抛出一个hook H failed java.lang.IllegalArgumentException;这是因为虽然mH属性对应的类继承了Handler对象,但是它的实际引用类型却是H。所以此路肯定是行不通的。这个时候我们不妨去看看[Handler.java]( )类中最终msg分发的dispatchMessage函数源码实现,如下:

public void dispatchMessage(Message msg) {
if (msg.callback != null) {
handleCallback(msg);
} else {
//重点我们看这里,如果当前Handler中的CallBack实例对象为空才会走到handleMessage方法
//因此我们可以为mH这个继承了Handler的实例对象构造一个实现了CallBack的接口实例对象,那说干就干
if (mCallback != null) {
if (mCallback.handleMessage(msg)) {
return;
}
}
handleMessage(msg);
}
}

所以我们首先构造一个实现了Handler中CallBack接口的实例对象,并通过反射的方式将这个实例对象赋值给mH对象。代码如下:

public static void hookH() {
try {
//通过反射获取到ActivityThread实例对象
Object sCurrentActivityThread = RefInvoke.getStaticFieldValue(RefInvoke.getField(“android.app.ActivityThread”, “sCurrentActivityThread”), RefInvoke.getClass(“android.app.ActivityThread”));
//获取到ActivityThread中的mH实例对象
Field mHField = RefInvoke.getField(sCurrentActivityThread.getClass(), “mH”);
Handler mH = (Handler) RefInvoke.getFieldValue(mHField, sCurrentActivityThread);
//首先通过反射获取到Handler中的mCallBack属性
//通过反射的方式将mH实例对象中的mCallBack属性赋值为ActivityThreadHandler的实例对象
RefInvoke.setFieldValue(RefInvoke.getField(Handler.class, “mCallback”), mH, new ActivityThreadHandler(mH));
Log.i(TAG, “hook H complete”);
} catch (Exception e) {
Log.i(TAG, "hook H failed " + e);
}
}

接着就是在CallBack中的handleMessage方法中对Activity的启动进行拦截了,然后将需要加载的Activity替换成插件中的Activity,并将加载Activity的ClassLoader对象替换成以插件apk生成的ClassLoader对象,最后在ActivityThread中实际所加载的Activity就是插件中的Activity了。

public boolean handleMessage(@NonNull Message message) {
int what = message.what;
switch (what) {
//这里为什么是159可以到ActivityThread中找到答案
case 159:
//首先获取从AMS中传递过来的ClientTransaction对象
Object object = message.obj;
try {
//这里的CallBack对象就是实现Activity生命周期的各个对象了
List mActivityCallbacks = RefInvoke.on(object, “getCallbacks”).invoke();
//获取开始执行Activity onCreate方法的实例对象,并将其中的Intent对象中的ComponentName对象替换成插件Activity对应的ComponentName对象
Class<?> mLaunchActivityItemCls = RefInvoke.getClass(“android.app.servertransaction.LaunchActivityItem”);
for (Object obj : mActivityCallbacks) {
if (mLaunchActivityItemCls.isInstance(obj)) {
Intent intent = getIntent(mLaunchActivityItemCls, obj);
if (null == intent) {
break;
}
//只对需要实现插件化的Activity进行拦截,防止出现误拦截的情况
String path = intent.getStringExtra(Constants.DEX_PATH);
if (TextUtils.isEmpty(path)) {
Log.i(TAG, “dex path is empty,so do need replace class loader”);
break;
}
//替换成加载插件类的ClassLoader
replaceClassloader(mLaunchActivityItemCls, obj, path);
//将实际需要加载的Activity替换成插件中的Activity
replace(intent);
break;
}
}
} catch (Exception e) {
Log.e(TAG, "getActivityToken failed " + e.getMessage());
}
break;
default:

}
mBase.handleMessage(message);
return true;

}


#### 2.3.2 ClassLoader的hook对于加载Activity的ClassLoader替换则稍显复杂了,因此在代码实现之前我们还是简单去看一下源码,了解一下如何将加载Activity的ClassLoader替换成加载插件中Activity的ClassLoader。**源码解析**ActivityThread中对Activity初始化是在performLaunchActivity中完成,部分源码如下:

private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
ActivityInfo aInfo = r.activityInfo;
//生成LoadedApk对象
if (r.packageInfo == null) {
r.packageInfo = getPackageInfo(aInfo.applicationInfo, r.compatInfo,
Context.CONTEXT_INCLUDE_CODE);
}

ComponentName component = r.intent.getComponent();
if (component == null) {component = r.intent.resolveActivity(mInitialApplication.getPackageManager());r.intent.setComponent(component);
}if (r.activityInfo.targetActivity != null) {component = new ComponentName(r.activityInfo.packageName,r.activityInfo.targetActivity);
}
//为当前启动的Activity生成对应的Context对象
ContextImpl appContext = createBaseContextForActivity(r);
Activity activity = null;
try {//获取ClassLoader对象以加载需要启动的Activity类java.lang.ClassLoader cl = appContext.getClassLoader();activity = mInstrumentation.newActivity(cl, component.getClassName(), r.intent);.........
} catch (Exception e) {if (!mInstrumentation.onException(activity, e)) {throw new RuntimeException("Unable to instantiate activity " + component+ ": " + e.toString(), e);}
}
............return activity;

}

在该方法中首先会根据要启动Activity中所携带的ApplicationInfo等对象生成LoadedApk对象,然后通过LoadedApk中携带的ClassLoader属性为当前需要启动的Activity生成对对应的Context对象,并通过该ClassLoader加载需要启动的Activity类。其中Context对象创建是在ContextImpl的createActivityContext方法中完成,部分源码如下:

static ContextImpl createActivityContext(ActivityThread mainThread,
LoadedApk packageInfo, ActivityInfo activityInfo, IBinder activityToken, int displayId,
Configuration overrideConfiguration) {
if (packageInfo == null) throw new IllegalArgumentException(“packageInfo”);

String[] splitDirs = packageInfo.getSplitResDirs();
//获取LoadedApk中的ClassLoader对象,并根据该ClassLoader创建对应的Context对象
ClassLoader classLoader = packageInfo.getClassLoader();
........
ContextImpl context = new ContextImpl(null, mainThread, packageInfo, activityInfo.splitName,activityToken, null, 0, classLoader);
.......
final ResourcesManager resourcesManager = ResourcesManager.getInstance(); context.setResources(resourcesManager.createBaseActivityResources(activityToken,packageInfo.getResDir(),splitDirs,packageInfo.getOverlayDirs(),packageInfo.getApplicationInfo().sharedLibraryFiles,displayId,overrideConfiguration,compatInfo,classLoader));
context.mDisplay = resourcesManager.getAdjustedDisplay(displayId,context.getResources());
return context;

}

既然最终加载Activity类的ClassLoader是从LoadedApk对象来的,所以我们只需要将上述getPackageInfo方法所得来的LoadedApk对象中的ClassLoader对象替换成通过插件apk生成的插件就行了。但是有个问题就是这里的getPackageInfo方法我们并不能hook住,因此并不能把握住该方法的调用时机,所以通过getPackageInfo方法生成的LoadedApk我们并不能动态的去替换掉;因此这里我们所采用的是直接通过getPackageInfo方法创建一个属于我们自己的LoadedApk对象,至于我们为什么可以这样做,还是需要去看一下源码才知道。performLaunchActivity中调用的getPackageInfo方法最终会调用到该方法的重载方法中,实现如下:

private LoadedApk getPackageInfo(ApplicationInfo aInfo, CompatibilityInfo compatInfo,
ClassLoader baseLoader, boolean securityViolation, boolean includeCode,
boolean registerPackage) {
final boolean differentUser = (UserHandle.myUserId() != UserHandle.getUserId(aInfo.uid));
synchronized (mResourcesManager) {
WeakReference ref;
if (differentUser) {
ref = null;
//最终会进入到这个if中
//可以看到的是首先会去mPackages这个ArrayMap中查找该Activity所属的包名是否存在LoadedApk的缓存,如果存在缓存则直接使用
} else if (includeCode) {
ref = mPackages.get(aInfo.packageName);
} else {
ref = mResourcePackages.get(aInfo.packageName);
}
//如果不存在缓存则重新生成LoadedApk对象,并添加到mPackages
LoadedApk packageInfo = ref != null ? ref.get() : null;
if (packageInfo == null || (packageInfo.mResources != null
&& !packageInfo.mResources.getAssets().isUpToDate())) {
if (localLOGV) Slog.v(TAG, (includeCode ? "Loading code package "
: "Loading resource-only package ") + aInfo.packageName
+ " (in " + (mBoundApplication != null
? mBoundApplication.processName : null)
+ “)”);
packageInfo =
new LoadedApk(this, aInfo, compatInfo, baseLoader,
securityViolation, includeCode &&
(aInfo.flags&ApplicationInfo.FLAG_HAS_CODE) != 0, registerPackage);

        if (mSystemThread && "android".equals(aInfo.packageName)) {packageInfo.installSystemApplicationInfo(aInfo,getSystemContext().mPackageInfo.getClassLoader());}if (differentUser) {// Caching not supported across users} else if (includeCode) {

作者2013年从java开发,转做Android开发,在小厂待过,也去过华为,OPPO等大厂待过,18年四月份进了阿里一直到现在。

参与过不少面试,也当面试官 面试过很多人。深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,不成体系的学习效果低效漫长,而且极易碰到天花板技术停滞不前!

我整理了一份阿里P7级别的最系统的Android开发主流技术,特别适合有3-5年以上经验的小伙伴深入学习提升。

主要包括阿里,以及字节跳动,腾讯,华为,小米,等一线互联网公司主流架构技术。如果你想深入系统学习Android开发,成为一名合格的高级工程师,可以收藏一下这些Android进阶技术选型

我搜集整理过这几年阿里,以及腾讯,字节跳动,华为,小米等公司的面试题,把面试的要求和技术点梳理成一份大而全的“ Android架构师”面试 Xmind(实际上比预期多花了不少精力),包含知识脉络 + 分支细节。

Java语言与原理;
大厂,小厂。Android面试先看你熟不熟悉Java语言

高级UI与自定义view;
自定义view,Android开发的基本功。

性能调优;
数据结构算法,设计模式。都是这里面的关键基础和重点需要熟练的。

NDK开发;
未来的方向,高薪必会。

前沿技术;
组件化,热升级,热修复,框架设计

网上学习 Android的资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。希望这份系统化的技术体系对大家有一个方向参考。

我在搭建这些技术框架的时候,还整理了系统的高级进阶教程,会比自己碎片化学习效果强太多,CodeChina上可见;

CodeChina开源项目地址:https://codechina.csdn.net/m0_60958482/android_p7

当然,想要深入学习并掌握这些能力,并不简单。关于如何学习,做程序员这一行什么工作强度大家都懂,但是不管工作多忙,每周也要雷打不动的抽出 2 小时用来学习。

630917710168)]

前沿技术;
组件化,热升级,热修复,框架设计

[外链图片转存中…(img-qqHH4ZEq-1630917710169)]

网上学习 Android的资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。希望这份系统化的技术体系对大家有一个方向参考。

我在搭建这些技术框架的时候,还整理了系统的高级进阶教程,会比自己碎片化学习效果强太多,CodeChina上可见;

CodeChina开源项目地址:https://codechina.csdn.net/m0_60958482/android_p7

当然,想要深入学习并掌握这些能力,并不简单。关于如何学习,做程序员这一行什么工作强度大家都懂,但是不管工作多忙,每周也要雷打不动的抽出 2 小时用来学习。

不出半年,你就能看出变化!

Android插件化-Activity篇,腾讯安卓开发面试相关推荐

  1. 腾讯安卓开发面试,腾讯+字节+阿里面经真题汇总,Android篇

    简介 首先,Android是不是真的找工作越来越难呢?这个可能是大家最关心的.这个受大的经济环境以及行业发展前景的影响,同时也和个人因素有关. 近期一方面是所在的公司招聘Java开发人员很难招到合适的 ...

  2. Android 插件化学习

    突然想到Android 的插件化开发,于是网上搜罗资料,初步认知demo如下:  主要思想:利用 类加载器ClassLoader实现. 解决主要问题:一个是65K 大小问题,另外可以动态加载apk实现 ...

  3. VirtualAPK:滴滴 Android 插件化的实践之路

    一.前言 在 Android 插件化技术日新月异的今天,开发并落地一款插件化框架到底是简单还是困难,这个问题不同人会有不同的答案.但是我相信,完成一个插件化框架的 Demo 并不是多难的事儿,然而要开 ...

  4. 【Android 插件化】Hook 插件化框架 ( 使用 Hook 方式替换插件 Activity 的 mResources 成员变量 )

    Android 插件化系列文章目录 [Android 插件化]插件化简介 ( 组件化与插件化 ) [Android 插件化]插件化原理 ( JVM 内存数据 | 类加载流程 ) [Android 插件 ...

  5. 【Android 插件化】Hook 插件化框架 ( Hook Activity 启动流程 | 反射获取 IActivityManager 对象 )

    Android 插件化系列文章目录 [Android 插件化]插件化简介 ( 组件化与插件化 ) [Android 插件化]插件化原理 ( JVM 内存数据 | 类加载流程 ) [Android 插件 ...

  6. 【Android 插件化】Hook 插件化框架 ( Hook Activity 启动流程 | Hook 点分析 )

    Android 插件化系列文章目录 [Android 插件化]插件化简介 ( 组件化与插件化 ) [Android 插件化]插件化原理 ( JVM 内存数据 | 类加载流程 ) [Android 插件 ...

  7. 【Android 插件化】Hook 插件化框架 ( 从 Hook 应用角度分析 Activity 启动流程 二 | AMS 进程相关源码 | 主进程相关源码 )

    Android 插件化系列文章目录 [Android 插件化]插件化简介 ( 组件化与插件化 ) [Android 插件化]插件化原理 ( JVM 内存数据 | 类加载流程 ) [Android 插件 ...

  8. 【Android 插件化】Hook 插件化框架 ( 从 Hook 应用角度分析 Activity 启动流程 一 | Activity 进程相关源码 )

    Android 插件化系列文章目录 [Android 插件化]插件化简介 ( 组件化与插件化 ) [Android 插件化]插件化原理 ( JVM 内存数据 | 类加载流程 ) [Android 插件 ...

  9. 【Android 插件化】Hook 插件化框架 ( Hook Activity 启动过程 | 静态代理 )

    Android 插件化系列文章目录 [Android 插件化]插件化简介 ( 组件化与插件化 ) [Android 插件化]插件化原理 ( JVM 内存数据 | 类加载流程 ) [Android 插件 ...

最新文章

  1. google的gn构建系统
  2. 基于脑电和特征加权阶段训练的驾驶员疲劳状态估计
  3. Java 单例设计模式
  4. 13.SpringMVC和Spring集成(一) 14.SpringMVC和Spring集成(二)
  5. wxWidgets:wxSpinDoubleEvent类用法
  6. QString转char*的问题
  7. Socket的getInputStream()方法
  8. unix和linux的区别
  9. 安岳天气预报软件测试,安岳天气预报15天
  10. json编辑器插件 vue_vue-json-editor json编辑器
  11. 一群人在网上直播自己怎么写代码,而且还有人爱看
  12. 第三代计算机的内存是,Intel正式发布Cooper Lake、第三代傲腾内存和新数据中心SSD...
  13. eyoucms使用入门 一
  14. 一文看懂互联网支付系统整体架构
  15. CPU内部原理,一文解析
  16. RJ45布线 568A 和568B布线标准
  17. ZXing二维码自定义绘画文字
  18. TimeShift QQ群组-欢迎各界友人加入喽
  19. php redis操作详解
  20. 联想开机启动项按哪个_联想笔记本按哪个键进入u盘启动

热门文章

  1. 从0开始学习 GitHub 系列之「06.团队合作利器 Branch」----转载自stormzhang 原创文章
  2. Scale-Equalizing Pyramid Convolution for Object Detection论文阅读
  3. 人工智能医疗:小荷健康竞品分析报告
  4. 原生javascript的账号密码登录验证
  5. 苹果iPhone手机如何安装Tiktok?最新IOS苹果TikTok抖音国际版下载免拔卡安装使用教程
  6. git push时rejected,解决non-fast-forward errors的办法
  7. jpa mysql_Spring boot通过JPA访问MySQL数据库
  8. 思维导图与概念图的区别是什么?
  9. 新手Mac需要了解哪些内容?Mac小白基础教程
  10. UE4 骨骼动画 蓝图中调节某一根骨骼