Android静态代码扫描效率优化与实践
总第366篇
2019年 第44篇
背景与问题
思考与策略
思考一:现有插件包含的扫描工具是否都是必需的?
扫描工具对比
扫码侧重点,对比各个工具分别能针对解决什么类型的问题;
内置规则种类,列举各个工具提供的能力覆盖范围;
扫描对象,对比各个工具针对什么样的文件类型扫描;
原理简介,简单介绍各个工具的扫描原理;
优缺点,简单对比各个工具扫描效率、扩展性、定制性、全面性上的表现。
注:FindBugs只支持Java1.0~1.8,已经被SpotBugs替代。鉴于部分老项目并没有迁移到Java8,目前我们并没有使用SpotBugs代替FindBugs的原因如下,详情参考官方文档。
同时,SpotBugs的作者也在讨论是否让SpotBugs支持老的Java版本,结论是不提供支持。
思考二:是否可以优化扫描过程?
静态代码扫描耗时分析
优化思路分析
思考三:是否支持增量扫描?
如何收集增量文件,包括源码文件和Class文件?
现在业界是否有增量扫描的方案,可行性如何,是否适用我们现状?
各个扫描工具如何来支持增量文件的扫描?
优化探索与实践
全量扫描优化
搜集所有Module目标文件集
static Set<Project> collectDepProject(Project project, BaseVariant variant, Set<Project> result = null) {
if (result == null) {
result = new HashSet<>()
}
Set taskSet = variant.javaCompiler.taskDependencies.getDependencies(variant.javaCompiler)
taskSet.each { Task task ->
if (task.project != project && hasAndroidPlugin(task.project)) {
result.add(task.project)
BaseVariant childVariant = getVariant(task.project)
if (childVariant.name == variant.name || "${variant.flavorName}${childVariant.buildType.name}".toLowerCase() == variant.name.toLowerCase()) {
collectDepProject(task.project, childVariant, result)
}
}
}
return result
}
目前文件集分为两类,一类是源码文件,另一类是字节码文件,分别可以如下处理:
projectSet.each { targetProject ->
if (targetProject.plugins.hasPlugin(CodeDetectorPlugin) && GradleUtils.hasAndroidPlugin(targetProject)) {
GradleUtils.getAndroidExtension(targetProject).sourceSets.all { AndroidSourceSet sourceSet ->
if (!sourceSet.name.startsWith("test") && !sourceSet.name.startsWith(SdkConstants.FD_TEST)) {
source sourceSet.java.srcDirs
}
}
}
}
注:上面的Source是CheckStyle Task的属性,用其来指定扫描的文件集合;
// 排除掉一些模板代码class文件
static final Collection<String> defaultExcludes = (androidDataBindingExcludes + androidExcludes + butterKnifeExcludes + dagger2Excludes).asImmutable()
List<ConfigurableFileTree> allClassesFileTree = new ArrayList<>()
ConfigurableFileTree currentProjectClassesDir = project.fileTree(dir: variant.javaCompile.destinationDir, excludes: defaultExcludes)
allClassesFileTree.add(currentProjectClassesDir)
GradleUtils.collectDepProject(project, variant).each { targetProject ->
if (targetProject.plugins.hasPlugin(CodeDetectorPlugin) && GradleUtils.hasAndroidPlugin(targetProject)) {
// 可能有的工程没有Flavor只有buildType
GradleUtils.getAndroidVariants(targetProject).each { BaseVariant targetProjectVariant ->
if (targetProjectVariant.name == variant.name || "${targetProjectVariant.name}".toLowerCase() == variant.buildType.name.toLowerCase()) {
allClassesFileTree.add(targetProject.fileTree(dir: targetProjectVariant.javaCompile.destinationDir, excludes: defaultExcludes))
}
}
}
}
注:收集到字节码文件集后,可以用通过FindBugsTask 的 Class 属性指定扫描,后文会详细介绍FindBugs Task相关属性。
全量扫描优化数据
增量扫描优化
增量扫描技术调研
针对Lint,我们可以借鉴现有实现思路,同时深入分析扫描原理,在3.x版本上寻找出增量扫描的解决方案。对于CheckStyle和FindBugs,我们需要了解工具的相关配置参数,为其指定特定的差异文件集合。
注:业界有一些增量扫描的案例,例如diff_cover,此工具主要是对单元测试整体覆盖率的检测,以增量代码覆盖率作为一个指标来衡量项目的质量,但是这跟我们的静态代码分析的需求不太符合。它有一个比较好的思路是找出差异的代码行来分析覆盖率,粒度比较细。但是对于静态代码扫描,仅仅的差异行不足以完成上下文的语义分析,尤其是针对FindBugs这类需要分析字节码的工具,获取的差异行还需要经过编译成Class文件才能进行分析,方案并不可取。
寻找增量修改文件
${targetBranch}:需要合入代码的目标分支地址;
${sourceCommitHash}:需要提交的代码hash值。
通过这两个参数执行以下一系列命令来获取与远程目标分支的差异文件。
git remote add upstream ${upstreamGitUrl}
git fetch upstream ${targetBranch}
git diff --name-only --diff-filter=dr $sourceCommitHash upstream/$targetBranch
2. 获取远程目标分支的更新;
3. 比较分支差异获取文件路径。
Lint扫描原理分析
在分析Lint增量扫描原理之前,先介绍一下Lint扫描的工作流程:
App Source Files
项目中的源文件,包括Java、XML、资源文件、proGuard等。
lint.xml
用于配置希望排除的任何 Lint 检查以及自定义问题严重级别,一般各个项目都会根据自身项目情况自定义的lint.xml来排除一些检查项。
lint Tool
一套完整的扫描工具用于对Android的代码结构进行分析,可以通过命令行、IDEA、Gradle命令三种方式运行lint工具。
lint Output
Lint扫描的输出结果。
从上面可以看出,Lint Tool就像一个加工厂,对投入进来的原料(源代码)进行加工处理(各种检测器分析),得到最终的产品(扫描结果)。Lint Tool作为一个扫描工具集,有多种使用方式。Android为我们提供了三种运行方式,分别是命令行、IDEA、Gradle任务。这三种方式最终都殊途同归,通过LintDriver来实现扫描。如下图所示:
compile 'com.android.tools.build:gradle:3.1.1'
compile 'com.android.tools.lint:lint-gradle:26.1.1'
我们可以得到如下所示的依赖:
Lint工具集的一个封装,实现了一组API接口,用于启动Lint。
lint-checks-26.1.1
一组内建的检测器,用于对这种描述好Issue进行分析处理。
lint-26.1.1
可以看做是依赖上面两个jar形成的一个基于命令行的封装接口形成的脚手架工程,我们的命令行、Gradle任务都是继承自这个jar包中相关类来做的实现。
lint-gradle-26.1.1
可以看做是针对Gradle任务这种运行方式,基于lint-26.1.1做了一些封装类。
lint-gradle-api-26.1.1
真正Gradle Lint任务在执行时调用的入口。
在理解清楚了以上几个jar的关系和作用之后,我们可以发现Lint的核心库其实是前三个依赖。后面两个其实是基于脚手架,对Gradle这种运行方式做的封装。最核心的逻辑在LintDriver的Analyze方法中。
fun analyze() {
...省略部分代码...
for (project in projects) {
fireEvent(EventType.REGISTERED_PROJECT, project = project)
}
registerCustomDetectors(projects)
...省略部分代码...
try {
for (project in projects) {
phase = 1
val main = request.getMainProject(project)
// The set of available detectors varies between projects
computeDetectors(project)
if (applicableDetectors.isEmpty()) {
// No detectors enabled in this project: skip it
continue
}
checkProject(project, main)
if (isCanceled) {
break
}
runExtraPhases(project, main)
}
} catch (throwable: Throwable) {
// Process canceled etc
if (!handleDetectorError(null, this, throwable)) {
cancel()
}
}
...省略部分代码...
}
主要是以下三个重要步骤:
registerCustomDetectors(projects)
Lint为我们提供了许多内建的检测器,除此之外我们还可以自定义一些检测器,这些都需要注册进Lint工具用于对目标文件进行扫描。这个方法主要做以下几件事情:
遍历每一个Project和它的依赖Library工程,通过client.findRuleJars来找出自定义的jar包;
通过client.findGlobalRuleJars找出全局的自定义jar包,可以作用于每一个Android工程;
从找到的jarFiles列表中,解析出自定义的规则,并与内建的Registry一起合并为CompositeIssueRegistry;需要注意的是,自定义的Lint的jar包存放位置是build/intermediaters/lint目录,如果是需要每一个工程都生效,则存放位置为~/.android/lint/。
computeDetectors(project)
这一步主要用来收集当前工程所有可用的检测器。
fun infer(projects: Collection<Project>?): EnumSet<Scope> {
if (projects == null || projects.isEmpty()) {
return Scope.ALL
}
// Infer the scope
var scope = EnumSet.noneOf(Scope::class.java)
for (project in projects) {
val subset = project.subset
if (subset != null) {
for (file in subset) {
val name = file.name
if (name == ANDROID_MANIFEST_XML) {
scope.add(MANIFEST)
} else if (name.endsWith(DOT_XML)) {
scope.add(RESOURCE_FILE)
} else if (name.endsWith(DOT_JAVA) || name.endsWith(DOT_KT)) {
scope.add(JAVA_FILE)
} else if (name.endsWith(DOT_CLASS)) {
scope.add(CLASS_FILE)
} else if (name.endsWith(DOT_GRADLE)) {
scope.add(GRADLE_FILE)
} else if (name == OLD_PROGUARD_FILE || name == FN_PROJECT_PROGUARD_FILE) {
scope.add(PROGUARD_FILE)
} else if (name.endsWith(DOT_PROPERTIES)) {
scope.add(PROPERTY_FILE)
} else if (name.endsWith(DOT_PNG)) {
scope.add(BINARY_RESOURCE_FILE)
} else if (name == RES_FOLDER || file.parent == RES_FOLDER) {
scope.add(ALL_RESOURCE_FILES)
scope.add(RESOURCE_FILE)
scope.add(BINARY_RESOURCE_FILE)
scope.add(RESOURCE_FOLDER)
}
}
} else {
// Specified a full project: just use the full project scope
scope = Scope.ALL
break
}
}
}
可以看到,如果Project的Subset为Null,Scope就为Scope.ALL,表示本次扫描会针对能检测的所有范围,相应地在扫描时也会用到所有全部的Detector来扫描文件;
if(scope.contains(Scope.JAVA_FILE)||scope.contains(Scope.ALL_JAVA_FILES)){
val checks = union(scopeDetectors[Scope.JAVA_FILE],scopeDetectors[Scope.ALL_JAVA_FILES])
if (checks != null && !checks.isEmpty()) {
val files = project.subset
if (files != null) {
checkIndividualJavaFiles(project, main, checks, files)
} else {
val sourceFolders = project.javaSourceFolders
val testFolders = if (scope.contains(Scope.TEST_SOURCES))
project.testSourceFolders
else
emptyList<File> ()
val generatedFolders = if (isCheckGeneratedSources)
project.generatedSourceFolders
else
emptyList<File> ()
checkJava(project, main, sourceFolders, testFolders, generatedFolders, checks)
}
}
}
这里更加明确,如果project.subset不为空,就对单独的Java文件扫描,否则,就对源码文件和测试目录以及自动生成的代码目录进行扫描。整个runFileDetectors的扫描顺序入下:
Scope.MANIFEST
Scope.ALL_RESOURCE_FILES)|| scope.contains(Scope.RESOURCE_FILE) || scope.contains(Scope.RESOURCE_FOLDER) || scope.contains(Scope.BINARY_RESOURCE_FILE)
scope.contains(Scope.JAVA_FILE) || scope.contains(Scope.ALL_JAVA_FILES)
scope.contains(Scope.CLASS_FILE) || scope.contains(Scope.ALL_CLASS_FILES) || scope.contains(Scope.JAVA_LIBRARIES)
scope.contains(Scope.GRADLE_FILE)
scope.contains(Scope.OTHER)
scope.contains(Scope.PROGUARD_FILE)
scope.contains(Scope.PROPERTY_FILE)
与[官方文档]的描述顺序一致。
/**
* Adds the given file to the list of files which should be checked in this
* project. If no files are added, the whole project will be checked.
*
* @param file the file to be checked
*/
public void addFile(@NonNull File file) {
if (files == null) {
files = new ArrayList<>();
}
files.add(file);
}
/**
* The list of files to be checked in this project. If null, the whole
* project should be checked.
*
* @return the subset of files to be checked, or null for the whole project
*/
@Nullable
public List<File> getSubset() {
return files;
}
注释也很明确的说明了只要Files不为Null,就会扫描指定文件,否则扫描整个工程。
Lint增量扫描Gradle任务实现
lint->LintGlobalTask:由TaskManager创建;
lintDebug、lintRelease、lintVitalRelease->LintPerVariantTask:由ApplicationTaskManager或者LibraryTaskManager创建,其中lintVitalRelease只在release下生成。
所以,在Android Gradle 插件中,应用于Lint的任务分别为LintGlobalTask和LintPerVariantTask。他们的区别是前者执行的是扫描所有Variant,后者执行只针对单独的Variant。而我们的增量扫描任务其实是跟Variant无关的,因为我们会把所有差异文件都收集到。无论是LintGlobalTask或者是LintPerVariantTask,都继承自LintBaseTask。最终的扫描任务在LintGradleExecution的runLint方法中执行,这个类位于lint-gradle-26.1.1中,前面提到这个库是基于Lint的API针对Gradle任务做的一些封装。
/** Runs lint on the given variant and returns the set of warnings */
private Pair<List<Warning>, LintBaseline> runLint(
@Nullable Variant variant,
@NonNull VariantInputs variantInputs,
boolean report, boolean isAndroid) {
IssueRegistry registry = createIssueRegistry(isAndroid);
LintCliFlags flags = new LintCliFlags();
LintGradleClient client =
new LintGradleClient(
descriptor.getGradlePluginVersion(),
registry,
flags,
descriptor.getProject(),
descriptor.getSdkHome(),
variant,
variantInputs,
descriptor.getBuildTools(),
isAndroid);
boolean fatalOnly = descriptor.isFatalOnly();
if (fatalOnly) {
flags.setFatalOnly(true);
}
LintOptions lintOptions = descriptor.getLintOptions();
if (lintOptions != null) {
syncOptions(
lintOptions,
client,
flags,
variant,
descriptor.getProject(),
descriptor.getReportsDir(),
report,
fatalOnly);
} else {
// Set up some default reporters
flags.getReporters().add(Reporter.createTextReporter(client, flags, null,
new PrintWriter(System.out, true), false));
File html = validateOutputFile(createOutputPath(descriptor.getProject(), null, ".html",
null, flags.isFatalOnly()));
File xml = validateOutputFile(createOutputPath(descriptor.getProject(), null, DOT_XML,
null, flags.isFatalOnly()));
try {
flags.getReporters().add(Reporter.createHtmlReporter(client, html, flags));
flags.getReporters().add(Reporter.createXmlReporter(client, xml, false));
} catch (IOException e) {
throw new GradleException(e.getMessage(), e);
}
}
if (!report || fatalOnly) {
flags.setQuiet(true);
}
flags.setWriteBaselineIfMissing(report && !fatalOnly);
Pair<List<Warning>, LintBaseline> warnings;
try {
warnings = client.run(registry);
} catch (IOException e) {
throw new GradleException("Invalid arguments.", e);
}
if (report && client.haveErrors() && flags.isSetExitCode()) {
abort(client, warnings.getFirst(), isAndroid);
}
return warnings;
}
2. 创建LintCliFlags;
3. 创建LintGradleClient,这里面传入了一大堆参数,都是从Gradle Android 插件的运行环境中获得;
4. 同步LintOptions,这一步是将我们在build.gralde中配置的一些Lint相关的DSL属性,同步设置给LintCliFlags,给真正的Lint 扫描核心库使用;
5. 执行Client的Run方法,开始扫描。
扫描的过程上面的原理部分已经分析了,现在我们思考一下如何构造增量扫描的任务。我们已经分析到扫描的关键点是client.run(registry),所以我们需要构造一个Client来执行扫描。一个想法是通过反射来获取Client的各个参数,当然这个思路是可行的,我们也验证过实现了一个用反射方式构造的Client。但是反射这种方式有个问题是丢失了从Gradle任务执行到调用Lint API开始扫描这一过程中做的其他事情,侵入性比较高,所以我们最终采用继承LintBaseTask自行实现增量扫描任务的方式。
FindBugs扫描简介
Gradle FindBugs任务属性分析
Classes
该属性表示我们要分析的Class文件集合,通常我们会把编译结果的Class目录用于扫描。
Classpath
分析目标集合中的Class需要用到的所有相关的Classes路径,但是并不会分析它们自身,只用于扫描。
Effort
包含MIN,Default,MAX,级别越高,分析得越严谨越耗时。
findbugsClasspath
Finbugs库相关的依赖路径,用于配置扫描的引擎库。
reportLevel
报告级别,分为Low,Medium,High。如果为Low,所有Bug都报告,如果为High,仅报告High优先级。
Reports
扫描结果存放路径。
通过以上属性解释,不难发现要FindBugs增量扫描,只需要指定Classes的文件集合就可以了。
FindBugs任务增量扫描分析
可以看到确实只扫描了一个文件。那么扫描到底使用了哪些输入数据呢,我们可以通过扫描结果的提示清楚看到:
源码目录列表,包含了工程中的Java目录,res目录,以及编译过程中生成的一些类目录;
需要分析的目标Class集合,为编译后的Build目录下的当前Java文件对应的Class文件;
Aux Classpath Entries,表示分析上面的目标文件需要用到的类路径。
所以,根据IDEA的扫描结果来看,我们在做增量扫描的时候需要解决上面这几个属性的获取。在前面我们分析的属性是Gradle在FindBugs lib的基础上,定义的一套对应的Task属性。真正的Finbugs属性我们可以通过[官方文档]或者源码中查到。
配置AuxClasspath
FileCollection buildClasses = project.fileTree(dir: "${project.buildDir}/intermediates/classes/${variant.flavorName}/${variant.buildType.name}",includes: classIncludes)
FileCollection targetClasspath = project.files()
GradleUtils.collectDepProject(project, variant).each { targetProject ->
GradleUtils.getAndroidVariants(targetProject).each { targetVariant ->
if (targetVariant.name.capitalize().equalsIgnoreCase(variant.name.capitalize())) {
targetClasspath += targetVariant.javaCompile.classpath
}
}
}
classpath = variant.javaCompile.classpath + targetClasspath + buildClasses
FindBugs增量扫描误报优化
class A {
public static String buildTime = "";
....
}
静态变量buildTime会被认为应该加上Final,但是其实其他类会对这个变量赋值。如果单独扫描类A文件,就会报缺陷BUG_TYPE_MS_SHOULD_BE_FINAL。我们通过FindBugs-IDEA插件来扫描验证,也同样会有一样的问题。要解决此类问题,需要找到谁依赖了类A,并且一同参与扫描,同时也需要找出类A依赖了哪些文件,简单来说:需要找出与类A有直接关联的类。为了解决这个问题,我们通过ASM来找出相关的依赖,具体如下:
void findAllScanClasses(ConfigurableFileTree allClass) {
allScanFiles = [] as HashSet
String buildClassDir = "${project.buildDir}/$FINDBUGS_ANALYSIS_DIR/$FINDBUGS_ANALYSIS_DIR_ORIGIN"
Set<File> moduleClassFiles = allClass.files
for (File file : moduleClassFiles) {
String[] splitPath = file.absolutePath.split("$FINDBUGS_ANALYSIS_DIR/$FINDBUGS_ANALYSIS_DIR_ORIGIN/")
if (splitPath.length > 1) {
String className = getFileNameNoFlag(splitPath[1],'.')
String innerClassPrefix = ""
if (className.contains('$')) {
innerClassPrefix = className.split('\\$')[0]
}
if (diffClassNamePath.contains(className) || diffClassNamePath.contains(innerClassPrefix)) {
allScanFiles.add(file)
} else {
Iterable<String> classToResolve = new ArrayList<String>()
classToResolve.add(file.absolutePath)
Set<File> dependencyClasses = Dependencies.findClassDependencies(project, new ClassAcceptor(), buildClassDir, classToResolve)
for (File dependencyClass : dependencyClasses) {
if (diffClassNamePath.contains(getPackagePathName(dependencyClass))) {
allScanFiles.add(file)
break
}
}
}
}
}
}
通过以上方式,我们可以解决一些增量扫描时出现的误报情况,相比IDEA工具,我们更进一步降低了扫描部分文件的误报率。
CheckStyle增量扫描
void configureIncrementScanSource() {
boolean isCheckPR = false
DiffFileFinder diffFileFinder
if (project.hasProperty(CodeDetectorExtension.CHECK_PR)) {
isCheckPR = project.getProperties().get(CodeDetectorExtension.CHECK_PR)
}
if (isCheckPR) {
diffFileFinder = new DiffFileFinderHelper.PRDiffFileFinder()
} else {
diffFileFinder = new DiffFileFinderHelper.LocalDiffFileFinder()
}
source diffFileFinder.findDiffFiles(project)
if (getSource().isEmpty()) {
println '没有找到差异java文件,跳过checkStyle检测'
}
}
优化结果数据
落地与沉淀
扫描工具通用性
扫描完整性保证
apply plugin: 'code-detector'
codeDetector {
// 配置静态代码检测报告的存放位置
reportRelativePath = rootProject.file('reports')
/**
* 远程仓库地址,用于配置提交pr时增量检测
*/
upstreamGitUrl = "ssh://git@xxxxxxxx.git"
checkStyleConfig {
/**
* 开启或关闭 CheckStyle 检测
* 开启:true
* 关闭:false
*/
enable = true
/**
* 出错后是否要终止检查
* 终止:false
* 不终止:true。配置成不终止的话 CheckStyleTask 不会失败,也不会拷贝错误报告
*/
ignoreFailures = false
/**
* 是否在日志中展示违规信息
* 显示:true
* 不显示:false
*/
showViolations = true
/**
* 统一配置自定义的 checkstyle.xml 和 checkstyle.xsl 的 uri
* 配置路径为:
* "${checkStyleUri}/checkstyle.xml"
* "${checkStyleUri}/checkstyle.xsl"
*
* 默认为 null,使用 CodeDetector 中的默认配置
*/
checkStyleUri = rootProject.file('codequality/checkstyle')
}
findBugsConfig {
/**
* 开启或关闭 Findbugs 检测
* 开启:true
* 关闭:false
*/
enable = true
/**
* 可选项,设置分析工作的等级,默认值为 max
* min, default, or max. max 分析更严谨,报告的 bug 更多. min 略微少些
*/
effort = "max"
/**
* 可选项,默认值为 high
* low, medium, high. 如果是 low 的话,那么报告所有的 bug
*/
reportLevel = "high"
/**
* 统一配置自定义的 findbugs_include.xml 和 findbugs_exclude.xml 的 uri
* 配置路径为:
* "${findBugsUri}/findbugs_include.xml"
* "${findBugsUri}/findbugs_exclude.xml"
* 默认为 null,使用 CodeDetector 中的默认配置
*/
findBugsUri = rootProject.file('codequality/findbugs')
}
lintConfig {
/**
* 开启或关闭 lint 检测
* 开启:true
* 关闭:false
*/
enable = true
/**
* 统一配置自定义的 lint.xml 和 retrolambda_lint.xml 的 uri
* 配置路径为:
* "${lintConfigUri}/lint.xml"
* "${lintConfigUri}/retrolambda_lint.xml"
* 默认为 null,使用 CodeDetector 中的默认配置
*/
lintConfigUri = rootProject.file('codequality/lint')
}
}
我们希望扫描插件可以灵活指定增量扫描还是全量扫描以应对不同的使用场景,比如已存在项目的接入、新项目的接入、打包时的检测等。
./gradlew ":${appModuleName}:assemble${ultimateVariantName}" -PdetectorEnable=true -PcheckStyleIncrement=true -PlintIncrement=true -PfindBugsIncrement=true -PcheckPR=${checkPR} -PsourceCommitHash=${sourceCommitHash} -PtargetBranch=${targetBranch} --stacktrace
希望一次任务可以暴露所有扫描工具发现的问题,当某一个工具扫描到问题后不终止任务,如果是本地运行在发现问题后可以自动打开浏览器方便查看问题原因。
def finalizedTaskArray = [lintTask,checkStyleTask,findbugsTask]
checkCodeTask.finalizedBy finalizedTaskArray
"open ${reportPath}".execute()
为了保证提交的PR不会引起打包问题影响包的交付,在PR时触发的任务实际为打包任务,我们将静态代码扫描任务挂接在打包任务中。由于我们的项目是多Flavor构建,在CI上我们将触发多个Job同时执行对应Flavor的增量扫描和打包任务。同时为了保证代码扫描的完整性,我们在真正的打包Job上执行全量扫描。
总结与展望
参考资料
作者简介
---------- END ----------
招聘信息
美团餐饮生态技术团队诚招Android、Java后端高级/资深工程师和技术专家,Base成都,欢迎有兴趣的同学投递简历到tech@meituan.com(邮件标题注明:美团餐饮生态技术团队)。
Android组件化方案及组件消息总线modular-event实战