一种更高效的组件自动注册方案(android组件化开发)
来源:互联网 发布:win7未识别的网络 编辑:程序博客网 时间:2024/06/05 05:08
*本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布
摘要:
在编译时,扫描即将打包到apk中的所有类,将所有组件类收集起来,通过修改字节码的方式生成注册代码到组件管理类中,从而实现编译时自动注册的功能,不用再关心项目中有哪些组件类了。
特点:不需要注解,不会增加新的类;性能高,不需要反射,运行时直接调用组件的构造方法;能扫描到所有类,不会出现遗漏;支持分级按需加载功能的实现。
前言
最近在公司做android组件化开发框架的搭建,采用组件总线的方式进行通信:提供一个基础库,各组件(IComponent接口的实现类)都注册到组件管理类(组件总线:ComponentManager)中,组件之间在同一个app内时,通过ComponentManager转发调用请求来实现通信(不同app之间的通信方式不是本文的主题,暂且略去)。但在实现过程中遇到了一个问题:
如何将不同module中的组件类自动注册到ComponentManager中?
目前市面上比较常用的解决方案是使用annotationProcessor:通过编译时注解动态生成组件映射表代码的方式来实现。但尝试过后发现有问题,因为编译时注解的特性只在源码编译时生效,无法扫描到aar包里的注解(project依赖、maven依赖均无效),也就是说必须每个module编译时生成自己的代码,然后要想办法将这些分散在各aar种的类找出来进行集中注册。
ARouter的解决方案是:
- 每个module都生成自己的java类,这些类的包名都是’com.alibaba.android.arouter.routes’
- 然后在运行时通过读取每个dex文件中的这个包下的所有类通过反射来完成映射表的注册,详见ClassUtils.java源码
运行时通过读取所有dex文件遍历每个entry查找指定包内的所有类名,然后反射获取类对象。这种效率看起来并不高。
ActivityRouter的解决方案是(demo中有2个组件名为’app’和’sdk’):
- 在主app module中有一个
@Modules({"app", "sdk"})
注解用来标记当前app内有多少组件,根据这个注解生成一个RouterInit类 - 在RouterInit类的init方法中生成调用同一个包内的RouterMapping_app.map
- 每个module生成的类(RouterMapping_app.java 和 RouterMapping_sdk.java)都放在com.github.mzule.activityrouter.router包内(在不同的aar中,但包名相同)
- 在RouterMapping_sdk类的map()方法中根据扫描到的当前module内所有路由注解,生成了调用Routers.map(…)方法来注册路由的代码
- 在Routers的所有api接口中最终都会触发RouterInit.init()方法,从而实现所有路由的映射表注册
这种方式用一个RouterInit类组合了所有module中的路由映射表类,运行时效率比扫描所有dex文件的方式要高,但需要额外在主工程代码中维护一个组件名称列表注解: @Modules({“app”, “sdk”})
有没有一种方式可以更高效地管理这个列表呢?
联想到之前用ASM框架自动生成代码的方式做了个AndAop插件用于自动插入指定代码到任意类的任意方法中,于是写了一个自动生成注册组件的gradle插件。
大致思路是:在编译时,扫描所有类,将符合条件的类收集起来,并通过修改字节码生成注册代码到指定的管理类中,从而实现编译时自动注册的功能,不用再关心项目中有哪些组件类了。不会增加新的class,不需要反射,运行时直接调用组件的构造方法。
性能方面:由于使用效率更高的ASM框架来进行字节码分析和修改,并过滤掉android/support
包中的所有类(还支持设置自定义的扫描范围),经公司项目实测,未代码混淆前所有dex文件总计12MB左右,扫描及代码插入的总耗时在2s-3s之间,相对于整个apk打包所花3分钟左右的时间来说可以忽略不计(运行环境:MacBookPro 15吋高配 Mid 2015)。
开发完成后,考虑到这个功能的通用性,于是升级组件扫描注册插件为通用的自动注册插件AutoRegister,支持配置多种类型的扫描注册,使用方式见github中的README文档。此插件现已用到组件化开发框架: CC中
升级后,AutoRegister插件的完整功能描述是:
在编译期扫描即将打包到apk中的所有类,并将指定接口的实现类(或指定类的子类)通过字节码操作自动注册到对应的管理类中。尤其适用于命令模式或策略模式下的映射表生成。
在组件化开发框架中,可有助于实现分级按需加载的功能:
- 在组件管理类中生成组件自动注册的代码
- 在组件框架第一次被调用时加载此注册表
- 若组件中有很多功能提供给外部调用,可以将这些功能包装成多个Processor,并将它们自动注册到组件中进行管理
- 组件被初次调用时再加载这些Processor
实现过程
第一步:准备工作
- 首先要知道如何使用Android Studio开发Gradle插件
- 了解TransformAPI:Transform API是从Gradle 1.5.0版本之后提供的,它允许第三方在打包Dex文件之前的编译过程中修改java字节码(自定义插件注册的transform会在ProguardTransform和DexTransform之前执行,所以自动注册的类不需要考虑混淆的情况).参考文章有:
- Android 热修复使用Gradle Plugin1.5改造Nuwa插件(主要看前半部分关于TransformAPI的介绍,Nuwa相关的内容可先忽略)
- 字节码修改框架(相比于Javassist框架ASM较难上手,但性能更高,但相学习难度阻挡不了我们对性能的追求):
- ASM英文文档
- ASM API文档
- Android 热修复方案Tinker(七) 插桩实现(主要看关于ASM使用的介绍及与transformAPI的结合)
第二步:构建插件工程
- 按照如何使用Android Studio开发Gradle插件文章中的方法创建好插件工程并发布到本地maven仓库(我是放在工程根目录下的一个文件夹中),这样我们就可以在本地快速调试了
build.gradle文件的部分内容如下:
apply plugin: 'groovy'apply plugin: 'maven'dependencies { compile gradleApi() compile localGroovy()}repositories { mavenCentral()}dependencies { compile 'com.android.tools.build:gradle:2.2.0'}//加载本地maven私服配置(在工程根目录中的local.properties文件中进行配置)Properties properties = new Properties()properties.load(project.rootProject.file('local.properties').newDataInputStream())def artifactory_user = properties.getProperty("artifactory_user")def artifactory_password = properties.getProperty("artifactory_password")def artifactory_contextUrl = properties.getProperty("artifactory_contextUrl")def artifactory_snapshot_repoKey = properties.getProperty("artifactory_snapshot_repoKey")def artifactory_release_repoKey = properties.getProperty("artifactory_release_repoKey")def maven_type_snapshot = true// 项目引用的版本号,比如compile 'com.yanzhenjie:andserver:1.0.1'中的1.0.1就是这里配置的。def artifact_version='1.0.1'// 唯一包名,比如compile 'com.yanzhenjie:andserver:1.0.1'中的com.yanzhenjie就是这里配置的。def artifact_group = 'com.billy.android'def artifact_id = 'autoregister'def debug_flag = true //true: 发布到本地maven仓库, false: 发布到maven私服task sourcesJar(type: Jar) { from project.file('src/main/groovy') classifier = 'sources'}artifacts { archives sourcesJar}uploadArchives { repositories { mavenDeployer { //deploy到maven仓库 if (debug_flag) { repository(url: uri('../repo-local')) //deploy到本地仓库 } else {//deploy到maven私服中 def repoKey = maven_type_snapshot ? artifactory_snapshot_repoKey : artifactory_release_repoKey repository(url: "${artifactory_contextUrl}/${repoKey}") { authentication(userName: artifactory_user, password: artifactory_password) } } pom.groupId = artifact_group pom.artifactId = artifact_id pom.version = artifact_version + (maven_type_snapshot ? '-SNAPSHOT' : '') pom.project { licenses { license { name 'The Apache Software License, Version 2.0' url 'http://www.apache.org/licenses/LICENSE-2.0.txt' } } } } }}
根目录的build.gradle文件中要添加本地仓库的地址及dependencies
buildscript { repositories { maven{ url rootProject.file("repo-local") } maven { url 'http://maven.aliyun.com/nexus/content/groups/public/' } google() jcenter() } dependencies { classpath 'com.android.tools.build:gradle:3.0.0-beta6' classpath 'com.github.dcendents:android-maven-gradle-plugin:1.4.1' classpath 'com.billy.android:autoregister:1.0.1' }}
2.在Transform类的transform方法中添加类扫描相关的代码
// 遍历输入文件inputs.each { TransformInput input -> // 遍历jar input.jarInputs.each { JarInput jarInput -> String destName = jarInput.name // 重名名输出文件,因为可能同名,会覆盖 def hexName = DigestUtils.md5Hex(jarInput.file.absolutePath) if (destName.endsWith(".jar")) { destName = destName.substring(0, destName.length() - 4) } // 获得输入文件 File src = jarInput.file // 获得输出文件 File dest = outputProvider.getContentLocation(destName + "_" + hexName, jarInput.contentTypes, jarInput.scopes, Format.JAR) //遍历jar的字节码类文件,找到需要自动注册的component if (CodeScanProcessor.shouldProcessPreDexJar(src.absolutePath)) { CodeScanProcessor.scanJar(src, dest) } FileUtils.copyFile(src, dest) project.logger.info "Copying\t${src.absolutePath} \nto\t\t${dest.absolutePath}" } // 遍历目录 input.directoryInputs.each { DirectoryInput directoryInput -> // 获得产物的目录 File dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY) String root = directoryInput.file.absolutePath if (!root.endsWith(File.separator)) root += File.separator //遍历目录下的每个文件 directoryInput.file.eachFileRecurse { File file -> def path = file.absolutePath.replace(root, '') if(file.isFile()){ CodeScanProcessor.checkInitClass(path, new File(dest.absolutePath + File.separator + path)) if (CodeScanProcessor.shouldProcessClass(path)) { CodeScanProcessor.scanClass(file) } } } project.logger.info "Copying\t${directoryInput.file.absolutePath} \nto\t\t${dest.absolutePath}" // 处理完后拷到目标文件 FileUtils.copyDirectory(directoryInput.file, dest) }}
CodeScanProcessor是一个工具类,其中CodeScanProcessor.scanJar(src, dest)
和CodeScanProcessor.scanClass(file)
分别是用来扫描jar包和class文件的
扫描的原理是利用ASM的ClassVisitor来查看每个类的父类类名及所实现的接口名称,与配置的信息进行比较,如果符合我们的过滤条件,则记录下来,在全部扫描完成后将调用这些类的无参构造方法进行注册
static void scanClass(InputStream inputStream) { ClassReader cr = new ClassReader(inputStream) ClassWriter cw = new ClassWriter(cr, 0) ScanClassVisitor cv = new ScanClassVisitor(Opcodes.ASM5, cw) cr.accept(cv, ClassReader.EXPAND_FRAMES) inputStream.close()}static class ScanClassVisitor extends ClassVisitor { ScanClassVisitor(int api, ClassVisitor cv) { super(api, cv) } void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { super.visit(version, access, name, signature, superName, interfaces) RegisterTransform.infoList.each { ext -> if (shouldProcessThisClassForRegister(ext, name)) { if (superName != 'java/lang/Object' && !ext.superClassNames.isEmpty()) { for (int i = 0; i < ext.superClassNames.size(); i++) { if (ext.superClassNames.get(i) == superName) { ext.classList.add(name) return } } } if (ext.interfaceName && interfaces != null) { interfaces.each { itName -> if (itName == ext.interfaceName) { ext.classList.add(name) } } } } } }}
3.记录目标类所在的文件,因为我们接下来要修改其字节码,将注册代码插入进去
static void checkInitClass(String entryName, File file) { if (entryName == null || !entryName.endsWith(".class")) return entryName = entryName.substring(0, entryName.lastIndexOf('.')) RegisterTransform.infoList.each { ext -> if (ext.initClassName == entryName) ext.fileContainsInitClass = file } }
4.扫描完成后,开始修改目标类的字节码(使用ASM的MethodVisitor来修改目标类指定方法,若未指定则默认为static块,即<clinit>
方法),生成的代码是直接调用扫描到的类的无参构造方法,并非通过反射
- class文件: 直接修改此字节码文件(其实是重新生成一个class文件并替换掉原来的文件)
- jar文件:复制此jar文件,找到jar包中目标类所对应的JarEntry,修改其字节码,然后替换原来的jar文件
import org.apache.commons.io.IOUtilsimport org.objectweb.asm.*import java.util.jar.JarEntryimport java.util.jar.JarFileimport java.util.jar.JarOutputStreamimport java.util.zip.ZipEntry/** * * @author billy.qi * @since 17/3/20 11:48 */class CodeInsertProcessor { RegisterInfo extension private CodeInsertProcessor(RegisterInfo extension) { this.extension = extension } static void insertInitCodeTo(RegisterInfo extension) { if (extension != null && !extension.classList.isEmpty()) { CodeInsertProcessor processor = new CodeInsertProcessor(extension) File file = extension.fileContainsInitClass if (file.getName().endsWith('.jar')) processor.insertInitCodeIntoJarFile(file) else processor.insertInitCodeIntoClassFile(file) } } //处理jar包中的class代码注入 private File insertInitCodeIntoJarFile(File jarFile) { if (jarFile) { def optJar = new File(jarFile.getParent(), jarFile.name + ".opt") if (optJar.exists()) optJar.delete() def file = new JarFile(jarFile) Enumeration enumeration = file.entries() JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(optJar)) while (enumeration.hasMoreElements()) { JarEntry jarEntry = (JarEntry) enumeration.nextElement() String entryName = jarEntry.getName() ZipEntry zipEntry = new ZipEntry(entryName) InputStream inputStream = file.getInputStream(jarEntry) jarOutputStream.putNextEntry(zipEntry) if (isInitClass(entryName)) { println('codeInsertToClassName:' + entryName) def bytes = referHackWhenInit(inputStream) jarOutputStream.write(bytes) } else { jarOutputStream.write(IOUtils.toByteArray(inputStream)) } inputStream.close() jarOutputStream.closeEntry() } jarOutputStream.close() file.close() if (jarFile.exists()) { jarFile.delete() } optJar.renameTo(jarFile) } return jarFile } boolean isInitClass(String entryName) { if (entryName == null || !entryName.endsWith(".class")) return false if (extension.initClassName) { entryName = entryName.substring(0, entryName.lastIndexOf('.')) return extension.initClassName == entryName } return false } /** * 处理class的注入 * @param file class文件 * @return 修改后的字节码文件内容 */ private byte[] insertInitCodeIntoClassFile(File file) { def optClass = new File(file.getParent(), file.name + ".opt") FileInputStream inputStream = new FileInputStream(file) FileOutputStream outputStream = new FileOutputStream(optClass) def bytes = referHackWhenInit(inputStream) outputStream.write(bytes) inputStream.close() outputStream.close() if (file.exists()) { file.delete() } optClass.renameTo(file) return bytes } //refer hack class when object init private byte[] referHackWhenInit(InputStream inputStream) { ClassReader cr = new ClassReader(inputStream) ClassWriter cw = new ClassWriter(cr, 0) ClassVisitor cv = new MyClassVisitor(Opcodes.ASM5, cw) cr.accept(cv, ClassReader.EXPAND_FRAMES) return cw.toByteArray() } class MyClassVisitor extends ClassVisitor { MyClassVisitor(int api, ClassVisitor cv) { super(api, cv) } void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { super.visit(version, access, name, signature, superName, interfaces) } @Override MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions) if (name == extension.initMethodName) { //注入代码到指定的方法之中 boolean _static = (access & Opcodes.ACC_STATIC) > 0 mv = new MyMethodVisitor(Opcodes.ASM5, mv, _static) } return mv } } class MyMethodVisitor extends MethodVisitor { boolean _static; MyMethodVisitor(int api, MethodVisitor mv, boolean _static) { super(api, mv) this._static = _static; } @Override void visitInsn(int opcode) { if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN)) { extension.classList.each { name -> if (!_static) { //加载this mv.visitVarInsn(Opcodes.ALOAD, 0) } //用无参构造方法创建一个组件实例 mv.visitTypeInsn(Opcodes.NEW, name) mv.visitInsn(Opcodes.DUP) mv.visitMethodInsn(Opcodes.INVOKESPECIAL, name, "<init>", "()V", false) //调用注册方法将组件实例注册到组件库中 if (_static) { mv.visitMethodInsn(Opcodes.INVOKESTATIC , extension.registerClassName , extension.registerMethodName , "(L${extension.interfaceName};)V" , false) } else { mv.visitMethodInsn(Opcodes.INVOKESPECIAL , extension.registerClassName , extension.registerMethodName , "(L${extension.interfaceName};)V" , false) } } } super.visitInsn(opcode) } @Override void visitMaxs(int maxStack, int maxLocals) { super.visitMaxs(maxStack + 4, maxLocals) } }}
5.接收扩展参数,获取需要扫描类的特征及需要插入的代码
找了很久没找到gradle插件接收自定义对象数组扩展参数的方法,于是退一步改用List<Map>
接收后再进行转换的方式来实现,以此来接收多个扫描任务的扩展参数
import org.gradle.api.Project/** * aop的配置信息 * @author billy.qi * @since 17/3/28 11:48 */class AutoRegisterConfig { public ArrayList<Map<String, Object>> registerInfo = [] ArrayList<RegisterInfo> list = new ArrayList<>() Project project AutoRegisterConfig(){} void convertConfig() { registerInfo.each { map -> RegisterInfo info = new RegisterInfo() info.interfaceName = map.get('scanInterface') def superClasses = map.get('scanSuperClasses') if (!superClasses) { superClasses = new ArrayList<String>() } else if (superClasses instanceof String) { ArrayList<String> superList = new ArrayList<>() superList.add(superClasses) superClasses = superList } info.superClassNames = superClasses info.initClassName = map.get('codeInsertToClassName') //代码注入的类 info.initMethodName = map.get('codeInsertToMethodName') //代码注入的方法(默认为static块) info.registerMethodName = map.get('registerMethodName') //生成的代码所调用的方法 info.registerClassName = map.get('registerClassName') //注册方法所在的类 info.include = map.get('include') info.exclude = map.get('exclude') info.init() if (info.validate()) list.add(info) else { project.logger.error('auto register config error: scanInterface, codeInsertToClassName and registerMethodName should not be null\n' + info.toString()) } } }}
import java.util.regex.Pattern/** * aop的配置信息 * @author billy.qi * @since 17/3/28 11:48 */class RegisterInfo { static final DEFAULT_EXCLUDE = [ '.*/R(\\$[^/]*)?' , '.*/BuildConfig$' ] //以下是可配置参数 String interfaceName = '' ArrayList<String> superClassNames = [] String initClassName = '' String initMethodName = '' String registerClassName = '' String registerMethodName = '' ArrayList<String> include = [] ArrayList<String> exclude = [] //以下不是可配置参数 ArrayList<Pattern> includePatterns = [] ArrayList<Pattern> excludePatterns = [] File fileContainsInitClass //initClassName的class文件或含有initClassName类的jar文件 ArrayList<String> classList = new ArrayList<>() RegisterInfo(){} boolean validate() { return interfaceName && registerClassName && registerMethodName } //用于在console中输出日志 @Override String toString() { StringBuilder sb = new StringBuilder('{') sb.append('\n\t').append('scanInterface').append('\t\t\t=\t').append(interfaceName) sb.append('\n\t').append('scanSuperClasses').append('\t\t=\t[') for (int i = 0; i < superClassNames.size(); i++) { if (i > 0) sb.append(',') sb.append(' \'').append(superClassNames.get(i)).append('\'') } sb.append(' ]') sb.append('\n\t').append('codeInsertToClassName').append('\t=\t').append(initClassName) sb.append('\n\t').append('codeInsertToMethodName').append('\t=\t').append(initMethodName) sb.append('\n\t').append('registerMethodName').append('\t\t=\tpublic static void ') .append(registerClassName).append('.').append(registerMethodName) sb.append('\n\t').append('include').append(' = [') include.each { i -> sb.append('\n\t\t\'').append(i).append('\'') } sb.append('\n\t]') sb.append('\n\t').append('exclude').append(' = [') exclude.each { i -> sb.append('\n\t\t\'').append(i).append('\'') } sb.append('\n\t]\n}') return sb.toString() } void init() { if (include == null) include = new ArrayList<>() if (include.empty) include.add(".*") //如果没有设置则默认为include所有 if (exclude == null) exclude = new ArrayList<>() if (!registerClassName) registerClassName = initClassName //将interfaceName中的'.'转换为'/' if (interfaceName) interfaceName = convertDotToSlash(interfaceName) //将superClassName中的'.'转换为'/' if (superClassNames == null) superClassNames = new ArrayList<>() for (int i = 0; i < superClassNames.size(); i++) { def superClass = convertDotToSlash(superClassNames.get(i)) superClassNames.set(i, superClass) if (!exclude.contains(superClass)) exclude.add(superClass) } //interfaceName添加到排除项 if (!exclude.contains(interfaceName)) exclude.add(interfaceName) //注册和初始化的方法所在的类默认为同一个类 initClassName = convertDotToSlash(initClassName) //默认插入到static块中 if (!initMethodName) initMethodName = "<clinit>" registerClassName = convertDotToSlash(registerClassName) //添加默认的排除项 DEFAULT_EXCLUDE.each { e -> if (!exclude.contains(e)) exclude.add(e) } initPattern(include, includePatterns) initPattern(exclude, excludePatterns) } private static String convertDotToSlash(String str) { return str ? str.replaceAll('\\.', '/').intern() : str } private static void initPattern(ArrayList<String> list, ArrayList<Pattern> patterns) { list.each { s -> patterns.add(Pattern.compile(s)) } }}
第三步: 在application中配置自动注册插件所需的相关扩展参数
在主app module的build.gradle文件中添加扩展参数,示例如下:
//auto register extension// 功能介绍:// 在编译期扫描将打到apk包中的所有类// 将 scanInterface的实现类 或 scanSuperClasses的子类// 并在 codeInsertToClassName 类的 codeInsertToMethodName 方法中生成如下代码:// codeInsertToClassName.registerMethodName(scanInterface)// 要点:// 1. codeInsertToMethodName 若未指定,则默认为static块// 2. codeInsertToMethodName 与 registerMethodName 需要同为static或非static// 自动生成的代码示例:/* 在com.billy.app_lib_interface.CategoryManager.class文件中 static { register(new CategoryA()); //scanInterface的实现类 register(new CategoryB()); //scanSuperClass的子类 } */apply plugin: 'auto-register'autoregister { registerInfo = [ [ 'scanInterface' : 'com.billy.app_lib_interface.ICategory' // scanSuperClasses 会自动被加入到exclude中,下面的exclude只作为演示,其实可以不用手动添加 , 'scanSuperClasses' : ['com.billy.android.autoregister.demo.BaseCategory'] , 'codeInsertToClassName' : 'com.billy.app_lib_interface.CategoryManager' //未指定codeInsertToMethodName,默认插入到static块中,故此处register必须为static方法 , 'registerMethodName' : 'register' // , 'exclude' : [ //排除的类,支持正则表达式(包分隔符需要用/表示,不能用.) 'com.billy.android.autoregister.demo.BaseCategory'.replaceAll('\\.', '/') //排除这个基类 ] ], [ 'scanInterface' : 'com.billy.app_lib.IOther' , 'codeInsertToClassName' : 'com.billy.app_lib.OtherManager' , 'codeInsertToMethodName' : 'init' //非static方法 , 'registerMethodName' : 'registerOther' //非static方法 ] ]}
总结
本文介绍了AutoRegister插件的功能及其在组件化开发框架中的应用。重点对其原理做了说明,主要介绍了此插件的实现过程,其中涉及到的技术点有TransformAPI、ASM、groovy相关语法、gradle机制。
本插件的所有代码及其用法demo已开源到github上,欢迎fork、start
接下来就用这个插件来为我们自动管理注册表吧!
转载请注明出处: http://blog.csdn.net/cdecde111/article/details/78074692
- 一种更高效的组件自动注册方案(android组件化开发)
- 【架构】android组件化方案,让团队开发更有效率
- Android 自动完全组件(SocialTokenAutoComplete)—— 一种更友好的方式@某人在社交应用中
- Android组件化方案
- android组件化方案
- Android组件化方案
- Android组件化方案
- Android组件化方案
- Android组件化方案
- Vue.js:轻量高效的前端组件化方案
- Vue.js:轻量高效的前端组件化方案
- Vue.js:轻量高效的前端组件化方案
- EvenBus:Android应用组件之间高效通信的开发库
- [原创] 组件的自动注册与卸载
- 一种开发组件管理工具
- Android插件化开发之解决OpenAtlas组件在宿主的注册问题
- Android 组件化开发
- android 组件化开发
- 鼠标经过,产品图片的局部放大
- pygame编写飞机大战(1)-准备
- JAVA自定义算法产生正态分布随机数
- 放弃一个深爱的人,是什么感受?
- Markdown 添加 Latex 数学公式
- 一种更高效的组件自动注册方案(android组件化开发)
- CSS(三)— 选择器
- OpenCV霍夫变换:霍夫线变换,霍夫圆变换合辑
- Spring入门(Bean配置项、生命周期等)
- Socket网络同步异步编程+邮件发送代码+TCP/IP原理解析
- 欢迎使用CSDN-markdown编辑器
- 德普叔叔的电影小集(一)~
- 程序设计语言A-week2
- [初学C#] 第二习题 : 快递跟踪信息查询