Android-Plugin-Framework
README: 中文
Android-Plugin-Framework是一个Android插件化框架,用于通过动态加载的方式免安装运行插件apk
最新版本: 0.0.65-snapshot
重要:需要在根目录的gradle.properties文件中配置android.enableAapt2=false
项目结构
文件夹 | 说明 |
---|---|
FairyPlugin | 插件框架包 |
Samples | 示例代码,包含宿主APP、各种Demo插件、各种场景测试 |
名词解释
名词 | 说明 |
---|---|
宿主 | 正常安装在设备上的apk |
容器 | 宿主中由框架创建的插件运行环境 |
插件 | 被宿主加载运行的apk |
独立插件 | 运行时不依赖宿主的插件,自身可以是完整app,也可以不是一个完整app |
非独立插件 | 运行时依赖宿主(类、资源)的插件,自身不是一个完整app |
插件进程 | 插件运行时所在进程,也即容器所在进程 |
独立插件和非独立插件, 不以是否可以单独安装运行来区分,仅以是否依赖宿主的类和资源来区分。
此项目主要目标是为了运行非独立插件,而不是任意第三方app。
尽管此框架支持独立插件,但目标并不是为了支持任意三方app,不同于平行空间或应用分身之类的产品。 非独立插件相比任意三方app来说,可以预见到其使用了哪些系统api和特性,而且所有行为都是可以预测的。而任意三方app是不可预测的。 框架的做法是按需hook,即需要用到哪些系统特性和api,就对哪些特性和api提供支持。这种做法对开发非独立插件和二方独立插件而言完全足够。 目前已经添加了对常用特性和api的支持,如需使用的api还未支持请联系作者。
FEATURE
- 框架透明, 插件开发与普通apk开发无异,无约定约束
- 支持非独立插件和独立插件(非任意三方)
- 支持四大组件/Application/Fragment/Accessibility/LaunchMode/so
- 支持插件Theme/Style,宿主Theme/Style,轻松支持基于主题属性的皮肤切换
- 支持插件发送Notification/时在RemoteViews中携带插件中的资源(只支持5.x及以上, 且不支持miui8)
- 支持插件热更新:即在插件模块已经被唤起的情况先安装新版本插件,无需重启插件进程(前提是插件高度内敛,宿主
不主动
持有插件中的任何对象) - 支持全局服务:即插件向容器注册一个服务,其他所有插件已经宿主都获取并调用此服务
- 支持DataBinding(仅限独立插件)
- 支持插件WebView加载插件本地HTML文件
- 支持插件Fragment/View内嵌宿主Activity中
- 支持FileProvider
LIMIT
- 不支持插件Activity转场动画使用插件中的动画资源
- 不支持插件Manifest中申请权限,所有权限必须预埋到宿主Manifest中
- 不支持第三方app试图唤起插件中的组件时直接使用插件组件的Intent。 第三方app要唤起插件中的静态组件,例如Activity/service/Provider,必须由宿主程序进行桥接,即此组件需同时预埋到宿主和插件的Manifest中
- 不支持android.app.NativeActivity
- 不支持当一个插件依赖另一个插件时,被插件依赖的包含资源
- 不支持插件中的webview弹出
原生Chrome组件
例如通过html的标签设置时间选择器。 说明:是否能支持原生组件取决于系统中使用WebView的实现。 如果是使用的Android System Webview,则可以支持。因为它packageId是以0x3f开头; 如果是使用的Chrome Webview,则不支持。因为它packageId是以0x7f开头,会和插件冲突。 这是采用Public.xml进行资源分组的缺陷。 - 可能不支持对插件或者宿主进行加壳加固处理,未尝试
HOW TO USE
宿主侧
1、 新建一个工程,作为宿主工程
2、 在宿主工程的build.gradle文件下添加如下3个配置
apply from: "https://raw.githubusercontent.com/limpoxe/Android-Plugin-Framework/master/FairyPlugin/host.gradle"
android {
defaultConfig {
//这个配置不可省略
applicationId 宿主app包名
}
}
dependencies {
//请务必使用@aar结尾,以中断依赖传递
implementation('com.limpoxe.fairy:FairyPlugin:0.0.64-snapshot@aar')
//可选,用于支持插件全局函数式服务,不使用全局函数式服务不需要添加此依赖
//implementation('com.limpoxe.support:android-servicemanager:1.0.5@aar')
}
fairy {
//可选配置,用于指定插件进程名。默认插件进程为单独的进程,进程名为":plugin"
//若设置为空串或者null即是使用宿主进程作为插件进程
//pluginProcess = ""
//pluginProcess = null
//pluginProcess = ":xxx"
}
3、 在宿主工程中新建一个类继承自Application类, 并配置到AndroidManifest.xml中并重写这个类的下面2个方法
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
//框架日志开关, 默认false
FairyGlobal.setLogEnable(true);
//首次加载插件会创建插件对象,比较耗时,通过弹出loading页来过渡。
//这个方法是设置首次加载插件时, 定制loading页面的UI, 不传即默认没有loading页
//在宿主中创建任意一个layout传进去即可
//注意:首次唤起插件组件时,如果是通过startActivityForResult唤起的,如果配置了loading页,
//则实际是先打开了loading页,再转到目标页面,此时会忽略ForResult的结果。这种情况下应该禁用loading页配置
FairyGlobal.setLoadingResId(R.layout.loading);
//是否支持插件中使用本地html, 默认false
FairyGlobal.setLocalHtmlenable(true);
//初始化框架
PluginLoader.initLoader(this);
}
@Override
public Context getBaseContext() {
return PluginLoader.fixBaseContextForReceiver(super.getBaseContext());
}
4、在宿主工程中通过下面3个方法进行最基本的插件操作
安装: PluginManagerHelper.installPlugin( SDcard上插件apk的路径 );
卸载: PluginManagerHelper.remove( 插件packageName );
列表: PluginManagerHelper.getPlugins();
5、通过构造一个插件组件Intent打开插件
例如打开插件的Launcher界面
Intent launchIntent = getPackageManager().getLaunchIntentForPackage( 插件packageName );
startActivity(launchIntent);
宿主编译完成后,会在outputs/distrubites目录下生成一个名为host.bar的基线包,作为编译插件的基线。 以上所有内容及更多详情可以参考Demo
插件侧
独立插件:
新建一个工程, 作为插件工程,无需任何其他配置,编译出来即可当插件apk安装到宿主中。
非独立插件:
1、新建一个工程, 作为插件工程。
2、在build.gradle中添加如下2个配置
apply from: "https://raw.githubusercontent.com/limpoxe/Android-Plugin-Framework/master/FairyPlugin/plugin.gradle"
android {
defaultConfig {
//这个配置不可省略
applicationId 插件app包名
}
}
dependencies {
//***这是demo中的示例,请根据自己的实际情况修改,作用是指向插件依赖的宿主基线包***
//支持文件、maven坐标等写法
//baselinePatch 'xxx:xxx:xxx@bar'
baselinePatch files(project(':Samples:PluginMain').getBuildDir().absolutePath + '/distributions/host.bar')
}
完成以上2步后即可编译出非独立插件,以上所有内容及更多详情可以参考Demo
Demo编译方法
a)如果是命令行中:
cd Android-Plugin-Framework
./gradlew clean
./gradlew assembleDebug
b)如果是studio中:
打开studio右侧gradle面板区,点clean、点assembleDebug。不要使用菜单栏的菜单编译。
重要:
1、由于编译脚本依赖Task.doLast, 使用其他编译方法(如菜单编译)可能不会触发Task.doLast导致编译失败
2、必须先编译宿主,再编译非独立插件。(这也是使用菜单栏编译会失败的原因之一)
原因很简单,既然是非独立插件,肯定是需要引用宿主的类和资源的。所以编译非独立插件时会用到编译宿主时的输出物
3、由于宿主和插件在同一个工程中,点击assembleDebug时编译顺序不可控,会导致每次clean后,首次assembleDebug会失败,此时重新编译即可
可能需要执行3次assembleDebug,
第一次是编译宿主,产生bar文件,
第二次是依赖bar编译插件,产生插件文件
第三次是重新编译宿主,将插件文件内置到宿主assets中
所以如果使用其他编译方法,请务必仔细阅读build.gradle,了解编译过程和依赖关系后可以自行调整编译脚本,否则可能会失败。
4、Demo中使用了arm平台的so,若在x86平台上测试Demo可能会有so异常,请自行适配so。
待插件编译完成后,即可通过宿主在运行时下载插件apk或者将插件apk复制到sdcard调用PluginManagerHelper.installPlugin("插件apk绝对路径")进行插件安装。
通常插件会内置一个版本到宿主中随宿主一起发布,则需要将插件配置到宿主的assets目录下,再编译一次宿主(即上述3中的第三次编译)。 配置方法如下:
dependencies {
//支持坐标依赖
//innerPlugin 'xxx:xxx:xxx@apk'
innerPlugin '/xx/xx/xx/xx.apk'
}
增加这个配置以后,宿主在打包时会将这个依赖的插件apk打包到宿主的assets目录中
其他指南
-
如何使非独立插件依赖其他插件
例:插件A依赖插件B,则需在插件A的manifest文件中的application节点下增加如下配置:
<uses-library android:name="这里填写被依赖的插件B的包名" android:required="true" /> 并在插件A的build.gradle文件中使用provided添加对插件B的jar的依赖。 此处uses-library与其原始含义无关,仅作一个配置项取巧使用。 限制:被插件依赖的插件只可以包含class和Manifest和assets等文件,不可以携带资源文件。可参考demo中的pluginTestBase工程
-
如何使独立插件依赖其他插件
同上。
-
如何将插件中的Fragment嵌入宿主中的Activity中
首先,需要将此宿主Activity的android:process属性配置为插件进程(插件的代码需在插件进程运行,因此fragment的所在的Activity也需要在插件进程中)
然后,在此插件的AndroidManifest.xml配置节点:
<exported-fragment android:name="这里为此Fragment设置一个唯一标识符Id" android:value="这里为此Fragment类全名"/> 此配置的目的是为了框架能够快速查找到目标Fragment的类名,而不必进行遍历。 在宿主Activity中创建此插件Fragment的实例: Class clazz = PluginLoader.loadPluginFragmentClassById(这里填写Fragment的唯一标识符Id); if (clazz != null) { Fragment fragment = (Fragment) clazz.newInstance(); } 可参考demo中的plugintest工程。 另:根据对宿主Activity的配置,此Fragment的写法又分为两种,见下文。
-
如何将插件的Fragment嵌入其他插件的Activity中
同上。区别在于此插件Activity无需再配置进程属性,因为插件就是运行在插件进程中。
-
插件UI可通过Fragment、pluginView或者直接使用Activity来实现
如果是Fragment,又分为3种: 1、Fragment运行在宿主中的任意的Activity中 2、Fragment运行在宿主中的指定的Activity中 3、Fragment运行在插件中的任意Activity中 首先需要明确的是,无论是哪种情况,插件Fragment中的Context都必须使用插件自身的Context。 之所以有上述3种情况,即是根据在插件Fragment中获取Context的方式不同来区分。 第1种,在插件Fragment中通过常规方法获取到的Context都是宿主Context,不可用于插件。 所以,需要约束Fragment中凡是要使用context的地方,都需要使用通过 PluginLoader.getDefaultPluginContext(FragmentClass)或者context.createPackageContext("插件包名") 来获取的插件context。 注意,对这种Fragment中调用其中View.getContext()返回的类型不是其所在的Activity,强转Activity会出错。其真实类型是PluginContextTheme。 若需要获取所在的Activity,需要通过View.getParent.getContext() 返回其所在的Activity对象, getParent的目的是拿到宿主控件 第2种,由于是"运行在宿主中的指定的Activity中",因此,仅需在指定的Activity上添加@PluginContainer注解,框架会根据此注解自动修正Activity的Context。 使得在插件Fragment中通过常规方法获取到的Context即是插件Context,可直接使用。也即对Fragment无约束。 第3种,对Activity和Fragment都无特别要求。 总结: 如果插件Fragment不是运行在自己的插件提供的Activity中,则要么约束Fragment的context的获取方法,要么约束其运行时的容器Activity的Context(通 过@PluginContainer注解指明使用哪个插件的Context) 如果插件Fragment是运行在自己的插件提供的Activity中,则对Fragment和Activity都无特别要求。 demo中都有例子。
-
如何将插件中的View是嵌入宿主中的Activity中使用
鉴于实际情况中一些特殊场景可能不适合使用Fragment,框架也支持直接将插件View嵌入宿主Activity中使用。
首先,需要将此宿主Activity的android:process属性配置为插件进程,在宿主的布局文件中嵌入pluginView 节点,通过class="View的类全名"来指定使用的控件,
其他用法和普通控件无异,具体参考demo
这些可以被嵌入插件view的宿主Activity,需要在Activity的class上添加@PluginContainer注解(无需指插件包名,插件包名通过pluginView节点指定), 框架借此来识别pluginVie标签并解析出插件包名。 注意,在嵌入式插件View中调用getContext()返回的类型不是其所在的Activity,而是插件PluginContextTheme类型。 若想获得所在宿主的Activity对象,需要使用((PluginContextTheme)View.getContext()).getOuter()获取, 或者通过View.getParent.getContext()来获取, getParent的目的是拿到宿主控件。
###7. 如何在插件使用宿主中定义的主题
插件中可以直接使用宿主中定义的主题。
例如,宿主中定义了一个主题为AppHostTheme,那么插件的Manifest文件中可直接使用android:theme="@style/AppHostTheme"来使用宿主主题
如果插件要扩展宿主主题,也可以直接使用。例如,在插件的style.xml中定义一个主题PluginTheme:<style name="PluginTheme" parent="AppHostTheme" />
以上写法,IDE中可能会标红,但是不影响编译和运行。
###8. 如何在插件使用宿主中定义的其他资源
分为在代码中和在资源文件中两种。代码中直接通过R即可使用。资源中,插件中可以直接使用宿主中定义的资源,但是写法和直接使用插件自己的资源略有不同, 通常应写为:
android:label="@string/string_in_plugin"
但是上面只适用资源在插件中的情况,如果资源是定义在宿主中,应使用下面的写法
android:label="@*xx.xx.xx:string/string_in_host"
其中,xx.xx.xx是宿主包名
以上写法,IDE中可能会标红,但是不影响编译和运行。
注意本条与上一条区别:插件中使用宿主主题,可以直接使用,但是使用宿主资源,需要带上*packageName:前缀
-
如何在插件中对外提供函数式服务(非Service组件,支持同进程和跨进程)
插件和外界交互,除了可以使用标准api交互之外,还提供了函数式服务 插件发布一个函数服务,只需要在AndroidManifest.xml配置<exported-service>节点 其他插件或者宿主即可调用此服务,具体参考demo
-
如何在插件中获取宿主包名
插件getPackageName返回的是插件包名。如果要在插件中获取宿主包名,使用框架提供的FakeUtil.getHostPackageName()函数,参数为插件的context,例如插件activity或者插件Application
-
如何在插件中使用需要appkey的三方sdk
插件中使用需要appkey的sdk,例如将微信分享、百度地图sdk、友盟分享sdk集成到插件,请参考demo:baidumapsdk,wxsdklibrary,仔细阅读。 需要使用appKey的sdk,通常需要使得sdk能同时正确的取到下面3个值, 1、packageName 2、meta-data 3、signatures SDK取到这3个值以后,会去它自身的服务器上验证appkey。 然而,在插件中调用getPackageName等等相应的系统api,得到的是插件的packageName,插件的meta-data,以及插件的signatures。 所以,在sdk平台上注册appkey时直接使用插件的包名,签名,然后将appkey的配置埋入插件的meta-data, 此种情况无需特别配置。插件集成此sdk即可正常使用,例如百度地图SDK即符合此种情形。 但是,实际应用中仍然会存在下面2种情况: 1、可能sdk需要通过其自身的app来进行校验或者交互。例如微信分享sdk,它需要唤醒微信App,再由微信App和宿主App进行验证和交互(第三方app要唤起插件中的静态组件必须由宿 主程序进行桥接,方法请参看wxsdklibrary工程的用法),绕过了插件。 此种sdk在平台上注册appkey时必须使用宿主的包名 2、可能由于特殊原因,在sdk平台上注册appkey时已经使用了宿主的包名,业务上不能再更换使用插件的包名进行注册。 以上两种情况,sdk在拿到插件的Context以后(通常是在sdk的init方法里面传入的插件Application), sdk借助插件Context取不到正确的packageName、meta-data、signatures。正确的值全部在宿主中,插件拿到的全部是插件自己的。 sdk在获取packageName、meta-data、signatures这3个信息,都是通过传入的Context调用其getXXXX或者context.getPackageManager().getXXX来获取。 因此,针对这两种case,需要在初始化插件sdk是,传入fakeContext而不是插件的Context来欺骗sdk,使其能拿到正确信息。 在demo中,微信sdk插件的FakeContext,即是用来解决上面所说的第一种情况。 demo中的fakeContext重写了需要的相关方法。 如要使用此类插件,请务必先完全理解上述解释,以及为何使用FakeContext可以达成目的。然后遇到各种相关问题都可迎刃而解。
-
如何混淆宿主和插件
若需要混淆宿主,请参考PluginMain工程下的混淆配置,以防止宿主混淆后框架异常。
若需要混淆非独立插件,步骤如下:
1、在宿主中开启混淆编译 2、在插件中开启混淆编译 3、宿主和插件的proguardRule文件中配置配置禁止压缩:-dontshrink 因为若宿主中的某些类或者方法,没有在宿主中使用过,则宿主在混淆的时候可能会删除了这些类和方法。 然而插件有可能会使用这些被删减的类和方法,这种情况需要在编译时宿主禁用代码压缩 执行这3个步骤之后,编译出来的非独立插件即为混淆后的插件 若混淆后出现运行时异常,请检查临时文件是否存在不该存在的类或者少了需要的类。 文件位于build/tmp/jarUnzip/host, 以及build/tmp/jarUnzip/plugin; host目录为宿主编译出来混淆后的jar包解压后目录,等同于对宿主反编译后得到class目录 plugin为插件编译出来混淆后的jar包解压后目录。正常情况下host目录的内容应该为plugin目录内容的子集。 且host目录存在的每一个文件,必定在plugin相同路径下存在,否则很可能是依赖配置错误或者mapping文件配置错误,会导致脚本在做diff时出现遗漏而引起class异常 插件最终的混淆后jar包,即是通过这两个目录diff后从plugin中剔除了所有在host中存在的文件后压缩而成。 插件混淆后的jar包和diff后的jar包,在插件outputs目录下都有备份。 --------------------------------------------------- 以Demo为例,启用PluginTest插件的Debug版本的混淆,方法如下: 1、修改PluginMain工程的build.gradle中的buildTypes.debug.minifyEnabled为true 2、修改PluginTest工程的build.gradle中的buildTypes.debug.minifyEnabled为true 3、检查宿主和插件的proguard-rules.pro文件,确保插件和宿主的混淆规则中都配置了禁止压缩:-dontshrink 4、在settings.gradle中注释掉PluginTest2; 5、clean && assembleDebug 这里需要注意的是插件开启混淆以后,需要在插件的proguard里面增加对插件Fragment的keep,否则如果此fragment没有在插件自身 使用,仅作为嵌入宿主使用,则progurad可能误以为这个类在插件中没有被使用过而被精简掉
-
如何使外部应用或者系统可以直接通过插件组件的Intent打开插件
由于插件并没有正常安装到系统中,插件组件的Intent不能被系统识别,因此外部应用或者系统需要直接唤起插件组件时,需要将插件Intent在宿主的Manifest中
也预置一份,并在IntentFilter增加STUB_EXACT配置,如: //添加Receeiver桥接//添加Activity桥接 <activity android:name="com.example.pluginmain.wxapi.WXEntryActivity" android:process=":plugin" android:exported="true"> <!--下面是额外添加的配置项,作用是使得框架将此组件配置识别为插件组件 --> <intent-filter> <action android:name="${applicationId}.STUB_EXACT" /> <category android:name="android.intent.category.DEFAULT" /> </intent-filter> </activity>
可以参考demo
-
如何使插件返回宿主包名
默认情况下,插件getPackageName返回插件包名。虽然框架中会在一些系统api上将packageName修正为宿主的, 但是仍然可能有些特殊原因,比如在插件中接了一些三方sdk,sdk中有些api框架没有修正,导致出现packageName错误。
这种情况下,如果通过上面提到的fakeContext无法解决,可以使用useHostPackageName="true"这个配置指定插件使用宿主包名。 将此配置加在插件manifest文件的节点中 Demo参考Admob。
-
如何添加自定义的Stub
框架内置的stub模版有限,特别是对Activity来说,配置组合起来会比较多,若插件stub模版不满足要求时,可以通过添加自定义的stub映射处理器来进行stub映射 使用 FairyGlobal.registStubMappingProcessor() 来添加自定义的stub映射处理器
先在宿主的manifest添加stub:
<activity android:name="${applicationId}.stub.XXX" //名字随便写 android:exported="true" android:process=":plugin" //添加一些需要的配置 android:process=":plugin" android:screenOrientation="sensor" android:configChanges="orientation" android:theme="@android:style/Theme"> </activity>
定义一个映射器(实现部分仅供参考,方便理解接口含义)
public class TestCoustProcessor implements StubMappingProcessor { private android.util.Pair<String, String> pair; @Override public int getType() { return TYPE_ACTIVITY; } @Override public String bindStub(PluginDescriptor pluginDescriptor, String pluginComponentClassName) { if (pluginComponentClassName.equals("x.y.z.in.pulgin.ABC")) {//填写要绑定的插件中的组件名称 String stub = "xx.xx.xx.in.host.XXX"; //填写在宿主manifest增加的stub pair = new Pair<>(stub, pluginComponentClassName); return pair.first; } return null; } @Override public void unBindStub(String stubClassName, String pluginStubClass) { if (pair != null && pair.first.equals(stubClassName) && pair.second.equals(pluginStubClass)) { pair = null; } } @Override public boolean isStub(String stubClassName) { String stub = "xx.xx.xx.in.host.XXX"; //填写在宿主manifest增加的stub return stubClassName.equals(stub); } @Override public String getBindedPluginClassName(String stubClassName) { if (pair != null && pair.first.equals(stubClassName)) { return pair.second; } return null; } }
在调用框架初始化函数之前,注册这个映射器
FairyGlobal.registStubMappingProcessor(new TestCoustProcessor());
注意事项
1、非独立插件中的class不能同时存在于宿主和插件程序中
如果插件和宿主共享依赖库,常见的如supportv4,那么编译插件的时候不可将共享库编译到插件当中,包括共享库的代码以及R文件。
只需在编译时以provided方式添加到classpath中,公共库仅参与编译,不参与打包。参看demo。
2、若插件中包含so,则需要在宿主的相应目录下添加至少一个so文件,以确保插件和宿主支持的so种类完全相同
例如:插件包含armv7a、arm64两种so文件,则宿主必须至少包含一个armv7a的so以及一个arm64的so。
若宿主本身不需要so文件,可随意创建一个假so文件放在宿主的相应目录下。例如pluginMain工程中的libstub.so其实只是一个txt文件。
需要占位so的原因是,宿主在安装时,系统会扫描宿主中的so的(根据文件夹判断)类型,决定宿主在哪种cpu模式下运行、并保持到系统设置里面。
(系统源码可查看com.android.server.pm.PackageManagerService.setBundledAppAbisAndRoots()方法)
例如32、64、还是x86等等。如果宿主中不包含so,系统默认会选择一个最适合当前设备的模式。
那么问题来了,如果系统默认选择的模式,和将来下载安装的插件中支持的so模式不匹配,则会出现so异常。
因此需要提前通知系统,宿主需要在哪些cpu模式下运行。提前通知的方式即内置占位so。
3、框架中对非独立插件做了签名校验。如果宿主是release模式,要求插件的签名和宿主的签名一致才允许安装。
这是为了验证插件来源合法性
4、需要在android studio中关闭instantRun选项。因为instantRun会替换apk的application配置导致框架异常
5、对非独立插件而言,在宿主中定义的所有主题,在非独立插件中都可以直接使用
不要在非独立插件中定义和宿主中相同的主题名称,否则会报各种重复定义的错误(其他资源可以同名重复,例如color、drawable)
最常见的错误,例如新建一个宿主工程后,studio会默认创建一个AppTheme主题,
再新建一个非独立插件工程,studio也会默认创建一个AppTheme主题,此时插件中编译就会报重复定义错误,
因为非独立插件已经包含了所有宿主中定义的主题, 此时只需要删除调插件中的AppTheme主题即可
6、本项目除master分支外,其他分支不会更新维护。
7、如果是非独立插件, 需要先编译宿主, 再编译插件,
如果是非独立插件, 需要先编译宿主, 再编译插件
如果是非独立插件, 需要先编译宿主, 再编译插件
重要的事情讲3遍!遇到编译问题请先编译宿主, 再编译插件。因为从配置可以看出非独立插件编译时需要依赖宿主编译时的输出物
以上所有内容及更多详情可以参考Demo
其他
联系作者:
Q:15871365851,添加时请注明插件开发
Q群:116993004,重要:添加前请务必仔细阅读此ReadMe!请务必仔细阅读Demo!