本文所讲的组件化案例是基于自己开源的组件化框架项目
github上地址github.com/HelloChenJi…
其中即时通讯(Chat)模块是单独的项目
github上地址github.com/HelloChenJi…
项目发展到一定阶段时,随着需求的增加以及频繁地变更,项目会越来越大,代码变得越来越臃肿,耦合会越来越多,开发效率也会降低,这个时候我们就需要对旧项目进行重构即模块的拆分,官方的说法就是组件化。
1、 现在Android项目中代码量达到一定程度,编译将是一件非常痛苦的事情,一般都需要变异5到6分钟。Android studio推出instant run由于各种缺陷和限制条件(比如采用热修复tinker)一般情况下是被关闭的。而组件化框架可以使模块单独编译调试,可以有效地减少编译的时间。
2、通过组件化可以更好的进行并行开发,因为我们可以为每一个模块进行单独的版本控制,甚至每一个模块的负责人可以选择自己的设计架构而不影响其他模块的开发,与此同时组件化还可以避免模块之间的交叉依赖,每一个模块的开发人员可以对自己的模块进行独立测试,独立编译和运行,甚至可以实现单独的部署。从而极大的提高了并行开发效率。
1、首先需要在config,gradle文件中设置isAlone=true
ext {isAlone = true;//false:作为Lib组件存在, true:作为application存在
2、然后Sync 下。
3、最后相应的模块(new、chat、live、music、app)进行运行即可。
config.gradle文件的配置情况
ext {isAlone = false;//false:作为集成模式存在, true:作为组件模式存在// 各个组件版本号的统一管理android = [compileSdkVersion: 24,buildToolsVersion: "25.0.2",minSdkVersion : 16,targetSdkVersion : 22,versionCode : 1,versionName : '1.0.0',]libsVersion = [// 第三方库版本号的管理supportLibraryVersion = "25.3.0",retrofitVersion = "2.1.0",glideVersion = "3.7.0",loggerVersion = "1.15",
// eventbusVersion = "3.0.0",gsonVersion = "2.8.0",butterknife = "8.8.0",retrofit = "2.3.0",rxjava = "2.1.1",rxjava_android = "2.0.1",rxlifecycle = "2.1.0",rxlifecycle_components = "2.1.0",dagger_compiler = "2.11",dagger = "2.11",greenDao = "3.2.2",arouter_api = "1.2.2",arouter_compiler = "1.1.3",transformations = "2.0.2",rxjava_adapter = "2.3.0",gson_converter = "2.3.0",scalars_converter = "2.3.0",rxpermission = "0.9.4",eventbus="3.0.0",support_v4="25.4.0",okhttp3="3.8.1"]// 依赖库管理dependencies = [appcompatV7 : "com.android.support:appcompat-v7:$rootProject.supportLibraryVersion",design : "com.android.support:design:$rootProject.supportLibraryVersion",cardview : "com.android.support:cardview-v7:$rootProject.supportLibraryVersion",palette : "com.android.support:palette-v7:$rootProject.supportLibraryVersion",recycleview : "com.android.support:recyclerview-v7:$rootProject.supportLibraryVersion",support_v4 : "com.android.support:support-v4:$rootProject.support_v4",annotations : "com.android.support:support-annotations:$rootProject.supportLibraryVersion",eventBus : "org.greenrobot:eventbus:$rootProject.eventbus",glide : "com.github.bumptech.glide:glide:$rootProject.glideVersion",gson : "com.google.code.gson:gson:$rootProject.gsonVersion",logger : "com.orhanobut:logger:$rootProject.loggerVersion",butterknife : "com.jakewharton:butterknife:$rootProject.butterknife",butterknife_compiler : "com.jakewharton:butterknife-compiler:$rootProject.butterknife",retrofit : "com.squareup.retrofit2:retrofit:$rootProject.retrofit",okhttp3 : "com.squareup.okhttp3:okhttp:$rootProject.retrofit",retrofit_adapter_rxjava2 : "com.squareup.retrofit2:adapter-rxjava2:$rootProject.rxjava_adapter",retrofit_converter_gson : "com.squareup.retrofit2:converter-gson:$rootProject.gson_converter",retrofit_converter_scalars: "com.squareup.retrofit2:converter-scalars:$rootProject.scalars_converter",rxpermission : "com.tbruyelle.rxpermissions2:rxpermissions:$rootProject.rxpermission@aar",rxjava2 : "io.reactivex.rxjava2:rxjava:$rootProject.rxjava",rxjava2_android : "io.reactivex.rxjava2:rxandroid:$rootProject.rxjava_android",rxlifecycle2 : "com.trello.rxlifecycle2:rxlifecycle:$rootProject.rxlifecycle",rxlifecycle2_components : "com.trello.rxlifecycle2:rxlifecycle-components:$rootProject.rxlifecycle_components",dagger2_compiler : "com.google.dagger:dagger-compiler:$rootProject.dagger_compiler",dagger2 : "com.google.dagger:dagger:$rootProject.dagger",greenDao : "org.greenrobot:greendao:$rootProject.greenDao",transformations : "jp.wasabeef:glide-transformations:$rootProject.transformations",
//路由通讯arouter_api : "com.alibaba:arouter-api:$rootProject.arouter_api",arouter_compiler : "com.alibaba:arouter-compiler:$rootProject.arouter_compiler"]
}
组件间通信的实现是采用阿里开源的Arouter路由通信。
github地址:github.com/alibaba/ARo…
在App工程中,初始化组件通信数据
private List
然后在设置每个item的点击事件时,启动组件界面跳转。
@Overridepublic void onItemClick(int position, View view) {MainItemBean item=mainAdapter.getData(position);ARouter.getInstance().build(item.getPath()).navigation();}
每个组件入口界面的设置(比如直播Live组件,其它组件类似)
@Route(path = "/live/main")
public class MainActivity extends BaseActivity, MainPresenter> implements View.OnClickListener {
我们通过判断组件处于哪种模式来动态设置项目res资源和Manifest、以及代码的位置。以直播组件为例,其它组件类似。
直播组件框架sourceSets {main {if (rootProject.ext.isAlone) {manifest.srcFile 'src/main/module/AndroidManifest.xml'java.srcDirs = ['src/main/java', 'src/main/module/java']res.srcDirs = ['src/main/res', 'src/main/module/res']} else {manifest.srcFile 'src/main/AndroidManifest.xml'}}}
采用类似于Glide在Manifest初始化配置的方式来初始化各个组件的Application,以直播组件为例,其它类似。
在BaseApplication中,初始化ApplicationDelegate代理类
@Overrideprotected void attachBaseContext(Context base) {super.attachBaseContext(base);applicationDelegate = new ApplicationDelegate();applicationDelegate.attachBaseContext(base);MultiDex.install(this);}
ApplicationDelegate内部是怎样的呢?继续看下去
public class ApplicationDelegate implements IAppLife {private List
// 初始化Manifest文件解析器,用于解析组件在自己的Manifest文件配置的ApplicationManifestParser manifestParser = new ManifestParser(base);list = manifestParser.parse();
//解析得到的组件Application列表之后,给每个组件Application注入
context,和Application的生命周期的回调,用于实现application的同步if (list != null && list.size() > 0) {for (IModuleConfig configModule :list) {configModule.injectAppLifecycle(base, appLifes);configModule.injectActivityLifecycle(base, liferecycleCallbacks);}}if (appLifes != null && appLifes.size() > 0) {for (IAppLife life :appLifes) {life.attachBaseContext(base);}}}@Overridepublic void onCreate(Application application) {
// 相应调用组件Application代理类的onCreate方法if (appLifes != null && appLifes.size() > 0) {for (IAppLife life :appLifes) {life.onCreate(application);}}if (liferecycleCallbacks != null && liferecycleCallbacks.size() > 0) {for (Application.ActivityLifecycleCallbacks life :liferecycleCallbacks) {application.registerActivityLifecycleCallbacks(life);}}}@Overridepublic void onTerminate(Application application) {
// 相应调用组件Application代理类的onTerminate方法if (appLifes != null && appLifes.size() > 0) {for (IAppLife life :appLifes) {life.onTerminate(application);}}if (liferecycleCallbacks != null && liferecycleCallbacks.size() > 0) {for (Application.ActivityLifecycleCallbacks life :liferecycleCallbacks) {application.unregisterActivityLifecycleCallbacks(life);}}}
}
组件Manifest中application的全局配置
"com.example.live.LiveApplication"android:value="IModuleConfig" />
ManifestParser会对其中value为IModuleConfig的meta-data进行解析,并通过反射生成实例。
public final class ManifestParser {private static final String MODULE_VALUE = "IModuleConfig";private final Context context;public ManifestParser(Context context) {this.context = context;}public List
//会对其中value为IModuleConfig的meta-data进行解析,并通过反射生成实例if (MODULE_VALUE.equals(appInfo.metaData.get(key))) {modules.add(parseModule(key));}}}} catch (PackageManager.NameNotFoundException e) {throw new RuntimeException("Unable to find metadata to parse IModuleConfig", e);}return modules;}//通过类名生成实例private static IModuleConfig parseModule(String className) {Class> clazz;try {clazz = Class.forName(className);} catch (ClassNotFoundException e) {throw new IllegalArgumentException("Unable to find IModuleConfig implementation", e);}Object module;try {module = clazz.newInstance();} catch (InstantiationException e) {throw new RuntimeException("Unable to instantiate IModuleConfig implementation for " + clazz, e);} catch (IllegalAccessException e) {throw new RuntimeException("Unable to instantiate IModuleConfig implementation for " + clazz, e);}if (!(module instanceof IModuleConfig)) {throw new RuntimeException("Expected instanceof IModuleConfig, but found: " + module);}return (IModuleConfig) module;}
这样通过以上步骤就可以在Manifest文件中配置自己组件的Application,用于初始化组件内的数据,比如在直播组件中初始化Dagger的全局配置
public class LiveApplication implements IModuleConfig,IAppLife {private static MainComponent mainComponent;@Overridepublic void injectAppLifecycle(Context context, List
// 这里需要把本引用添加到Application的生命周期的回调中,以便实现回调iAppLifes.add(this);}@Overridepublic void injectActivityLifecycle(Context context, List
// 在onCreate方法中对Dagger进行初始化mainComponent= DaggerMainComponent.builder().mainModule(new MainModule()).appComponent(BaseApplication.getAppComponent()).build();}@Overridepublic void onTerminate(Application application) {if (mainComponent != null) {mainComponent = null;}}public static MainComponent getMainComponent() {return mainComponent;}
}
由于每个组件的BaseUrl和网络配置等可能不一样,所以每个组件可以在自己配置的dagger中的 MainConponent实现自己的网络请求和拦截器。
以直播组件为例,其它类似。
MainComponent
@PerApplication
@Component(dependencies = AppComponent.class, modules = MainModule.class)
public interface MainComponent {public DaoSession getDaoSession();public MainRepositoryManager getMainRepositoryManager();
}
MainModule代码
@Module
public class MainModule {@Provides@PerApplicationpublic MainRepositoryManager provideRepositoryManager(@Named("live") Retrofit retrofit, DaoSession daoSession) {return new MainRepositoryManager(retrofit, daoSession);}@Provides@Named("live")@PerApplicationpublic Retrofit provideRetrofit(@Named("live") OkHttpClient okHttpClient,@Nullable Gson gson){Retrofit.Builder builder=new Retrofit.Builder().baseUrl(LiveUtil.BASE_URL).addCallAdapterFactory(RxJava2CallAdapterFactory.create()).addConverterFactory(GsonConverterFactory.create(gson)).client(okHttpClient);return builder.build();}@Provides@Named("live")@PerApplicationpublic OkHttpClient provideOkHttpClient(@Named("live")LiveInterceptor interceptor){OkHttpClient.Builder builder=new OkHttpClient.Builder();builder.connectTimeout(10, TimeUnit.SECONDS).readTimeout(10,TimeUnit.SECONDS);builder.addInterceptor(interceptor);return builder.build();}@Provides@Named("live")@PerApplicationpublic LiveInterceptor provideNewsInterceptor(){return new LiveInterceptor();}
}
greendao数据库初始化代码,在基类库的NetClientModule.java中
public DaoSession provideDaoSession(Application application) {DaoMaster.DevOpenHelper devOpenHelper = new DaoMaster.DevOpenHelper(application, "common_library_db", null);Database database = devOpenHelper.getWritableDb();DaoMaster master = new DaoMaster(database);return master.newSession();}
其中的DaoMaster是通过APT生成的,由于DaoMaster给全局的组件使用,所以只能将greendao 数据库放在基类库中,并且各个组件的实体类bean的创建也只能在基类库中进行,以分包命名进行区分,如下图。因为如果在组件内创建bean 会重新生成另一个副本DaoMaster并且不能操控其他组件的数据库实体,有很大的局限性。
基类库组件实体分包图官方说法是在每个module的build.gradle文件中配置资源文件名前缀
这种方法缺点就是,所有的资源名必须要以指定的字符串(moudle_prefix)做前缀,否则会异常报错,而且这方法只限定xml里面的资源,对图片资源并不起作用,所以图片资源仍然需要手动去修改资源名。
所以不是很推荐使用这种方法来解决资源名冲突。所以只能自己注意点,在创建资源的时候,尽量不让其重复。
resourcePrefix "moudle_prefix"
虽然Butterknife支持在lib中使用&#xff0c;但是条件是用 R2 代替 R &#xff0c;在组件模式和集成模式的切换中&#xff0c;R2<->R之间的切换是无法完成转换的&#xff0c;切换一次要改动全身&#xff0c;是非常麻烦的&#xff01;所以不推荐在组件化中使用Butterknife。
1、可能大家会认为&#xff0c;每个组件都依赖基类库&#xff0c;基类库library次不是重复依赖了&#xff1f;其实并不会存在这样的问题&#xff0c;因为在构建APP的过程中Gradle会自动将重复的arr包排除&#xff0c;也就不会存在重复依赖基类库的情况。
2、但是第三方开源库依赖的包可能会与我们自己引用的包重复&#xff0c;所以我们需要将多余的包给排除出去。
基类库(CommonLibrary)中build.gradle
dependencies {compile fileTree(dir: &#39;libs&#39;, include: [&#39;*.jar&#39;])testCompile &#39;junit:junit:4.12&#39;androidTestCompile(&#39;com.android.support.test.espresso:espresso-core:2.2.2&#39;, {exclude group: &#39;com.android.support&#39;, module: &#39;support-annotations&#39;})compile(rootProject.ext.dependencies.appcompatV7) {exclude module: "support-v4"exclude module: "support-annotations"}compile rootProject.ext.dependencies.recycleviewcompile rootProject.ext.dependencies.designcompile(rootProject.ext.dependencies.support_v4) {exclude module: "support-annotations"}compile rootProject.ext.dependencies.annotationscompile(rootProject.ext.dependencies.butterknife) {exclude module: &#39;support-annotations&#39;}compile rootProject.ext.dependencies.rxjava2compile(rootProject.ext.dependencies.rxjava2_android) {exclude module: "rxjava"}compile(rootProject.ext.dependencies.rxlifecycle2) {exclude module: &#39;rxjava&#39;exclude module: &#39;jsr305&#39;}compile(rootProject.ext.dependencies.rxlifecycle2_components) {exclude module: &#39;support-v4&#39;exclude module: &#39;appcompat-v7&#39;exclude module: &#39;support-annotations&#39;exclude module: &#39;rxjava&#39;exclude module: &#39;rxandroid&#39;exclude module: &#39;rxlifecycle&#39;}compile(rootProject.ext.dependencies.retrofit) {exclude module: &#39;okhttp&#39;exclude module: &#39;okio&#39;}compile(rootProject.ext.dependencies.retrofit_converter_gson) {exclude module: &#39;gson&#39;exclude module: &#39;okhttp&#39;exclude module: &#39;okio&#39;exclude module: &#39;retrofit&#39;}compile(rootProject.ext.dependencies.retrofit_adapter_rxjava2) {exclude module: &#39;rxjava&#39;exclude module: &#39;okhttp&#39;exclude module: &#39;retrofit&#39;exclude module: &#39;okio&#39;}compile rootProject.ext.dependencies.greenDaocompile rootProject.ext.dependencies.okhttp3compile rootProject.ext.dependencies.gsoncompile rootProject.ext.dependencies.glidecompile rootProject.ext.dependencies.eventBuscompile rootProject.ext.dependencies.dagger2compile(rootProject.ext.dependencies.rxpermission) {exclude module: &#39;rxjava&#39;}compile rootProject.ext.dependencies.retrofit_converter_scalarsannotationProcessor rootProject.ext.dependencies.dagger2_compilerannotationProcessor rootProject.ext.dependencies.butterknife_compilercompile rootProject.ext.dependencies.butterknifecompile rootProject.ext.dependencies.transformationscompile rootProject.ext.dependencies.arouter_api
}
本开源项目是基于腾讯的bugly平台&#xff0c;用于监控异常信息、热修复和应用升级。
具体实现&#xff1a;
1、在工程的根目录build.gradle配置
buildscript {repositories {jcenter()}dependencies {classpath "com.tencent.bugly:tinker-support:1.0.8"}
}
然后在App 的build.gradle进行以下配置
dependencies {compile fileTree(include: [&#39;*.jar&#39;], dir: &#39;libs&#39;)androidTestCompile(&#39;com.android.support.test.espresso:espresso-core:2.2.2&#39;, {exclude group: &#39;com.android.support&#39;, module: &#39;support-annotations&#39;})if (!rootProject.ext.isAlone) {compile project(&#39;:chat&#39;)compile project(&#39;:music&#39;)compile project(&#39;:news&#39;)compile project(&#39;:live&#39;)apt rootProject.ext.dependencies.arouter_compiler} else {compile project(&#39;:commonlibrary&#39;)}testCompile &#39;junit:junit:4.12&#39;
// 依赖bugly相关SDKcompile &#39;com.tencent.bugly:crashreport_upgrade:1.3.1&#39;compile &#39;com.tencent.bugly:nativecrashreport:latest.release&#39;
}
apply from: &#39;tinker-support.gradle&#39;
然后依赖其中的插件脚本
apply from: &#39;tinker-support.gradle&#39;
其中的tinker-support.gradle文件如下&#xff1a;
apply plugin: &#39;com.tencent.bugly.tinker-support&#39;
def bakPath &#61; file("${buildDir}/bakApk/")
/*** 此处填写每次构建生成的基准包目录*/
def baseApkDir &#61; "app-0831-17-50-44"
/*** 对于插件各参数的详细解析请参考*/
tinkerSupport {// 开启tinker-support插件&#xff0c;默认值trueenable &#61; true// 自动生成tinkerId, 你无须关注tinkerId&#xff0c;默认为falseautoGenerateTinkerId &#61; true// 指定归档目录&#xff0c;默认值当前module的子目录tinkerautoBackupApkDir &#61; "${bakPath}"// 是否启用覆盖tinkerPatch配置功能&#xff0c;默认值false// 开启后tinkerPatch配置不生效&#xff0c;即无需添加tinkerPatchoverrideTinkerPatchConfiguration &#61; true// 编译补丁包时&#xff0c;必需指定基线版本的apk&#xff0c;默认值为空// 如果为空&#xff0c;则表示不是进行补丁包的编译// &#64;{link tinkerPatch.oldApk }baseApk &#61; "${bakPath}/${baseApkDir}/app-release.apk"// 对应tinker插件applyMappingbaseApkProguardMapping &#61; "${bakPath}/${baseApkDir}/app-release-mapping.txt"// 对应tinker插件applyResourceMappingbaseApkResourceMapping &#61; "${bakPath}/${baseApkDir}/app-release-R.txt"// 构建基准包跟补丁包都要修改tinkerId&#xff0c;主要用于区分tinkerId &#61; "1.0.5-base_patch"// 打多渠道补丁时指定目录// buildAllFlavorsDir &#61; "${bakPath}/${baseApkDir}"// 是否使用加固模式&#xff0c;默认为false// isProtectedApp &#61; true// 是否采用反射Application的方式集成&#xff0c;无须改造ApplicationenableProxyApplication &#61; true
}
/*** 一般来说,我们无需对下面的参数做任何的修改* 对于各参数的详细介绍请参考:* https://github.com/Tencent/tinker/wiki/Tinker-%E6%8E%A5%E5%85%A5%E6%8C%87%E5%8D%97*/
tinkerPatch {tinkerEnable &#61; trueignoreWarning &#61; falseuseSign &#61; truedex {dexMode &#61; "jar"pattern &#61; ["classes*.dex"]loader &#61; []}lib {pattern &#61; ["lib/*/*.so"]}res {pattern &#61; ["res/*", "r/*", "assets/*", "resources.arsc", "AndroidManifest.xml"]ignoreChange &#61; []largeModSize &#61; 100}packageConfig {}sevenZip {zipArtifact &#61; "com.tencent.mm:SevenZip:1.1.10"
// path &#61; "/usr/local/bin/7za"}buildConfig {keepDexApply &#61; false
// tinkerId &#61; "base-2.0.1"}
}
然后需要在Manifest配置文件配置如下
最后在Application中初始化bugly
public class App extends BaseApplication {&#64;Overridepublic void onCreate() {super.onCreate();setStrictMode();// 设置是否开启热更新能力&#xff0c;默认为trueBeta.enableHotfix &#61; true;// 设置是否自动下载补丁Beta.canAutoDownloadPatch &#61; true;// 设置是否提示用户重启Beta.canNotifyUserRestart &#61; true;// 设置是否自动合成补丁Beta.canAutoPatch &#61; true;/*** 全量升级状态回调*/Beta.upgradeStateListener &#61; new UpgradeStateListener() {&#64;Overridepublic void onUpgradeFailed(boolean b) {}&#64;Overridepublic void onUpgradeSuccess(boolean b) {}&#64;Overridepublic void onUpgradeNoVersion(boolean b) {Toast.makeText(getApplicationContext(), "最新版本", Toast.LENGTH_SHORT).show();}&#64;Overridepublic void onUpgrading(boolean b) {Toast.makeText(getApplicationContext(), "onUpgrading", Toast.LENGTH_SHORT).show();}&#64;Overridepublic void onDownloadCompleted(boolean b) {}};/*** 补丁回调接口&#xff0c;可以监听补丁接收、下载、合成的回调*/Beta.betaPatchListener &#61; new BetaPatchListener() {&#64;Overridepublic void onPatchReceived(String patchFileUrl) {Toast.makeText(getApplicationContext(), patchFileUrl, Toast.LENGTH_SHORT).show();}&#64;Overridepublic void onDownloadReceived(long savedLength, long totalLength) {Toast.makeText(getApplicationContext(), String.format(Locale.getDefault(),"%s %d%%",Beta.strNotificationDownloading,(int) (totalLength &#61;&#61; 0 ? 0 : savedLength * 100 / totalLength)), Toast.LENGTH_SHORT).show();}&#64;Overridepublic void onDownloadSuccess(String patchFilePath) {Toast.makeText(getApplicationContext(), patchFilePath, Toast.LENGTH_SHORT).show();
// Beta.applyDownloadedPatch();}&#64;Overridepublic void onDownloadFailure(String msg) {Toast.makeText(getApplicationContext(), msg, Toast.LENGTH_SHORT).show();}&#64;Overridepublic void onApplySuccess(String msg) {Toast.makeText(getApplicationContext(), msg, Toast.LENGTH_SHORT).show();}&#64;Overridepublic void onApplyFailure(String msg) {Toast.makeText(getApplicationContext(), msg, Toast.LENGTH_SHORT).show();}&#64;Overridepublic void onPatchRollback() {Toast.makeText(getApplicationContext(), "onPatchRollback", Toast.LENGTH_SHORT).show();}};long start &#61; System.currentTimeMillis();// 这里实现SDK初始化&#xff0c;appId替换成你的在Bugly平台申请的appId,调试时将第三个参数设置为trueBugly.init(this, "2e5309db50", true);long end &#61; System.currentTimeMillis();}&#64;Overrideprotected void attachBaseContext(Context base) {super.attachBaseContext(base);// you must install multiDex whatever tinker is installed!MultiDex.install(base);// 安装tinkerBeta.installTinker();}&#64;TargetApi(9)protected void setStrictMode() {StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder().permitAll().build());StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder().detectAll().penaltyLog().build());}
}
该组件框架是自己在暑假实习期间做的&#xff0c;由于实习公司的项目过于庞大和复杂&#xff0c;每次编译都需要花费10几分钟&#xff0c;心都碎了&#xff0c;所以才想尝试下组件化框架&#xff0c;摸索了很长时间&#xff0c;最后还是做出来了&#xff0c;大概花费2个多月的时间&#xff0c;由于最近项目上比较忙&#xff0c;所以没什么时间来完善&#xff0c;界面有点简陋&#xff0c;但逻辑基本实现了。欢迎fork and star。
有对组件化框架兴趣的同学可以加本人QQ1981367757&#xff0c;一起探讨技术。
github上地址: github.com/HelloChenJi…