- 首先把工程代码clone到本地,https://github.com/bytedance/ByteX,checkout一个feature分支做开发.
- ByteX的代码组织是一个module对应一个plugin,每个plugin的代码相互独立,相互隔离.因此,要开发一个新插件,你需要新建一个Java Library module.
- 新建完module后,配置插件的build.gradle(插件的名字就是module的名字,插件的名字规范请参考 下面的开发注意事项-开发规范)
//使用通用的依赖和发布方式
apply from: rootProject.file('gradle/plugin.gradle')
//如果module需要单独添加依赖则添加下面的
dependencies {
compile project(':TransformEngine')
implementation "br.usp.each.saeg:asm-defuse:0.0.5"
}
这样工程框架就搭建好了.
- 基于ByteX开发新插件,我们最少只需要创建两个类.
- 一个是Extension,它是一个数据结构,对应于插件在app工程里的配置信息.
- 一个是Plugin,它务必实现Plugin接口.当然,对于简单的插件,直接继承抽象类AbsMainProcessPlugin或者CommonPlugin就是省事的做法了.下面是一个简单的例子:
//@PluginConfig("bytex.sourcefile")
public class SourceFileKillerPlugin extends CommonPlugin<SourceFileExtension, SourceFileContext> {
@Override
protected SourceFileContext getContext(Project project, AppExtension android, SourceFileExtension extension) {
return new SourceFileContext(project, android, extension);
}
@Override
public boolean transform(@Nonnull String relativePath, @Nonnull ClassVisitorChain chain) {
//我们需要修改字节码,所以需要注册一个ClassVisitor
//We need to modify the bytecode, so we need to register a ClassVisitor
chain.connect(new SourceFileClassVisitor(extension));
return super.transform(relativePath, chain);
}
@Nonnull
@Override
public TransformConfiguration transformConfiguration() {
return new TransformConfiguration() {
@Override
public boolean isIncremental() {
//插件默认是增量的,如果插件不支持增量,需要返回false
//The plugin is incremental by default.It should return false if incremental is not supported by the plugin
return true;
}
};
}
}
- 创建好Plugin类后,我们需要让gradle能够识别到我们的plugin.我们有两种配置方式.
- 可以用注解的方式
@PluginConfig("bytex.sourcefile")
public class SourceFileKillerPlugin extends CommonPlugin<SourceFileExtension, SourceFileContext> {...}
- 传统的properties
需要在resource目录创建一个properties文件.properties文件的文件名对应于插件的id,可以随意起但务必保证在工程中的唯一性.例如下图中,properties文件名是bytex.sourcefile.properties,那么在app工程里要引用这个插件时就需要这样配置:
在properties文件里需要配置我们的Plugin类的全类名(包名+类名),例如:
implementation-class=com.ss.android.ugc.bytex.sourcefilekiller.SourceFileKillerPlugin
那么在app工程里要引用这个插件时就需要这样配置:
apply plugin: 'bytex.sourcefile'
至此,我们的新插件初现雏形了,但要让我们的插件处理class文件,需要定义相应的ClassVisitor或者直接操作ClassNode.
- 本地发布
直接在工程根目录执行脚本
./publish.sh
或者双击uploadArchives即可将插件发布到本地工程的gralde_plugins目录.
- 线上发布
如果我们的插件在本地开发并自测完成后,需要将插件发布到线上的公司maven,方便集成到实际项目的当中.
首先在ByteX的根目录下新建(如果有就不用新建了)一个local.properties的配置文件,添加如下配置:
UPLOAD_MAVEN_URL=xxx
UPLOAD_MAVEN_URL_SNAPSHOT=xxx
USERNAME=xxx
PASSWORD=xxx
USERNAME_SNAPSHOT=xxx
PASSWORD_SNAPSHOT=xxx
然后,修改ext.gradle里的upload_version.
同样地,执行脚本(或者双击uploadArchives即可将插件发布到线上maven.)
./publish.sh -m
- 发布到snapshot version=$当前版本号-${user.name}--SNAPSHOT
./publish.sh -m -t
在AndroidStudio新建run configuration.
把插件本地发布然后接入到app工程里之后,在命令行执行构建命令,末尾拼上这一串参数,比如:
./gradlew clean :example:assembleDouyinCnRelease -Dorg.gradle.debug=true --no-daemon
然后切换到刚刚创建的Configuration,点击debug按钮.
SourceFileKiller是一个自定义插件,代码比较少,可以当做示例demo.它做的事情很简单.删除字节码中的SourceFile与行号信息
如果需要在外部工程,基于ByteX开发插件,只需要引入common依赖即可.
compile gradleApi()
compileOnly "com.android.tools.build:gradle:$gradle_version"
compile "com.bytedance.android.byteX:common:${bytex_version}"
如果想用注解的方式注册插件,可以引入以下依赖(非必须):
compile "com.bytedance.android.byteX:PluginConfigProcessor:${bytex_version}"
kapt "com.bytedance.android.byteX:PluginConfigProcessor:${bytex_version}"
由于ByteX的上层封装是基于ASM的,因此我们处理class文件时,可以通过注册ClassVisitor或者直接操作ClassNode的方式来对class文件进行读写操作.(如果你需要接收文件的byte code作为输入,可以参考下面的Advanced API).
默认地,ByteX所形成的Transform对Class文件至少有一个常规处理过程(MainProcess),包括以下步骤:
- traverse过程:遍历一次工程中所有的构建产物(一般来说是class文件),单纯做遍历分析,不对输入文件做修改;
- traverseAndroidJar过程:遍历android.jar里的所有class文件 (哪个版本的android.jar由工程中的target api决定),主要是为了形成完整的类图.
- 最后一步transform:再遍历一次工程中所有的构建产物,并对class文件做处理后输出(可能是直接回写到本地,也可能作为下一个处理过程的输入).
由此可以看出,一次常规处理过程,会遍历两次工程构建中的所有class.事实上,这样的处理过程我们抽象成一个TransformFlow,开发者可以通过自定义TransformFlow来定制工作流的处理步骤(比如,多次traverse,或者只做transform不做traverse等等),具体请参考Advanced API.
回到我们之前新建的Plugin类,它是直接继承自CommonPlugin的,如果我们需要在transform阶段(对应上面的第三步)处理class文件,Plugin类需要Override下面两个方法的一个:
/**
* transform 工程中的所有class
*
* @param relativePath class的相对路径
* @param chain ClassVisitorChain用于注册自定义的ClassVisitor
* @return if true, 这个class文件会正常输出;if false, 这个class文件会被删除
*/
@Override
public boolean transform(String relativePath, ClassVisitorChain chain) {
chain.connect(new HookFlavorClassVisitor(context));
return true;
}
/**
* transform 工程中的所有class
*
* @param relativePath class的相对路径
* @param node class的数据结构(照顾喜欢用tree api的同学).
* @return if true, 这个class文件会正常输出;if false, 这个class文件会被删除
*/
@Override
public boolean transform(String relativePath, ClassNode node) {
// do something with ClassNode
return true;
}
我们可以看到,这两个重载方法的区别在于他们的输入参数,前者用的是ASM的ClassVisitor,后者用的是ASM 的Tree API,可以直接处理ClassNode.
同理,如果我们需要需要在traverse阶段,分析class文件,Plugin类可以复写下面的方法:
/**
* 遍历工程中所有的class
*
* @param relativePath class的相对路径
* @param chain ClassVisitorChain用于加入自定义的ClassVisitor
*/
void traverse(@Nonnull String relativePath, @Nonnull ClassVisitorChain chain);
/**
* 遍历工程中所有的class
*
* @param relativePath class的相对路径
* @param node class的数据结构(照顾喜欢用tree api的同学).
*/
void traverse(@Nonnull String relativePath, @Nonnull ClassNode node);
bytex对于对项目的每一处修改都要求产生一条日志,以便查询修改和后面的问题定位.bytex的日志设计是基于模块划分的,也就是每一个plugin的日志应该单独记录在一个日志文件中,这个已经封装好了,开发者只需和平常的Log调用一样拿来使用即可.
需要记录日志时直接从Context获取Logger对象并调用相应的方法.
对应的日志会记录在app/build/ByteX/${variantName}/${extension_name}/${logFile}文件中.如果在gradle中没有配置logFile参数,文件名则会使用${extension_name}log.txt.
bytex同时会生成一份可视化的html日志(多个transform会有多个html),这个页面的数据来源于每一个plugin,不需要开发者关心,自动生成.文件名为app/build/ByteX/ByteX_report{transformName}.html.
建议:如果插件需要生成其他文件信息,可以放在context.buildDir()目录中,这个目录对应app/build/ByteX/${extension_name}/
文件路径
为了给基于ByteX开发的插件提供更多的灵活性.我们引入了TransformFlow的概念.
处理全部的构建产物(一般为class文件)的过程定义为一次TransformFlow.一个插件可以独立使用单独的TransformFlow,也可以搭车到全局的MainTransformFlow(traverse,traverseAndroidJar,transform形成一个MainTransformFlow).
要为插件自定义TransformFlow,我们需要复写IPlugin的provideTransformFlow方法.
// 搭车到全局的MainTransformFlow, 大多数插件的做法
@Override
protected TransformFlow provideTransformFlow(@Nonnull MainTransformFlow mainFlow, @Nonnull TransformContext transformContext) {
return mainFlow.appendHandler(this);
}
// 插件独立于全局的MainTransformFlow,关联于新的MainTransformFlow
@Override
protected TransformFlow provideTransformFlow(@Nonnull MainTransformFlow mainFlow, @Nonnull TransformContext transformContext) {
return new MainTransformFlow(transformer, new BaseContext(project, android, extension));
}
// 插件独立于全局的MainTransformFlow,关联于新的自定义Flow
@Override
protected TransformFlow provideTransformFlow(@Nonnull MainTransformFlow mainFlow, @Nonnull TransformContext transformContext) {
return new AbsTransformFlow(transformer, new BaseContext(project, android, extension)) {
@Override
protected AbsTransformFlow beforeTransform(Transformer transformer) {
return this;
}
@Override
protected AbsTransformFlow afterTransform(Transformer transformer) {
return this;
}
@Override
public void run() throws IOException, InterruptedException {
// do something in flow.
}
};
}
理论上每一个TransformFlow都可能存在一个包含项目代码中的java类、依赖库中的java类和android.jar中的java类类图数据,这个和你TransformFlow的实现有关.
当你使用MainTransformFlow时(默认情况就是这个TransformFlow),在执行完traverse(包含traverseArtifactOnly和traverseAndroidJarOnly)后,插件会自动生成本次TransformFlow的类图数据,这个数据将会放在对应的Context对象中.你可以使用context.getClassGraph()方法获取类图对象.
public class BaseContext<E extends BaseExtension> {
protected final Project project;
protected final AppExtension android;
public final E extension;
private ILogger logger;
private Graph classGraph;//类图对象
...
public Graph getClassGraph() {
return classGraph;
}
}
注意点:
- 在TransformFlow没有完成traverse任务(准确的说是在CommonPlugin的beforeTransform)的情况下类图是不存在,获取类图的对象将会为null.
- 每一个继承自CommonPlugin的插件如果复写了beforeTransform方法必须调用对应super方法,否则类图对象不会传递到当前插件的Context对象中.
- 两个TransformFlow的类图隔离.因为每一个TransformFlow正常情况下都会对class修改,所以一般两个TransformFlow产生的类图也是不一样的.
ByteX提供了基础的INPUT读取与输出能力,如果有需求获取transform输入、project输入、aar输入等信息,可以借助Engine层提供的数据接口获取对应的文件输入.
比如需要获取transform的所有输入:
context.getTransformContext().allFiles()
比如需要获取merge之后的resource文件:
context.getTransformContext().getArtifact(Artifact.MERGED_RES)
比如需要查找某class的原始位置:
context.getTransformContext().getLocator().findLocation("${className}.class",SearchScope.ORIGIN)
MainProcessHandler绑定于MainTransformFlow的处理器,每个步骤处理的每个class都会回调MainProcessHandler的相应方法进行处理.一般我们自定义的插件都已经实现了这个接口,开发者可以直接复写相应的方法来获取相应的回调.
-
MainProcessHandler接口里面,init,traverse,transform的系列方法都是通过ASM处理class文件的,为了提供更大的灵活性,可以通过复写
List<FileProcessor> process(Process process)
方法,注册自己的FileProcessor来处理,以获得更大的灵活性. -
MainProcessHandler接口还有flagForClassReader方法,复写它可以自定义ClassReader调用accept方法读取class文件时传进去的flag,默认值是
ClassReader.SKIP_DEBUG
|ClassReader.SKIP_FRAMES
.
如果开发者不想用ASM封装的上层接口来处理class文件,ByteX也提供了更加底层的API.
FileProcessor类似于OkHttp的拦截器设计,每个class文件都会流经一系列的FileProcessor来处理.用这个接口的好处是,灵活! 作为一个拦截器,你可以让后面的FileProcessor先处理完后再处理,甚至可以不传给下面的FileProcessor处理.
public interface FileProcessor {
Output process(Chain chain) throws IOException;
interface Chain {
Input input();
Output proceed(Input input) throws IOException;
}
}
public class CustomFileProcessor implements FileProcessor {
@Override
public Output process(Chain chain) throws IOException {
Input input = chain.input();
FileData fileData = input.getFileData();
// do something with fileData
return chain.proceed(input);
}
}
注册自定义的FileProcessor,我们也提供了更加简便的注解方式,在自定义的Plugin上通过注解@Processor
来注册FileProcessor.
@Processor(implement = CustomFileProcessor.class)
@Processor(implement = CustomFileProcessor.class, process = Process.TRAVERSE)
public class FlavorCodeOptPlugin extends CommonPlugin<Extension, Context> {...}
FileHandler是FileProcessor再往上封装的接口,接受的输入参数是FileData,FileData内包含文件的bytecode.
public interface FileHandler {
void handle(FileData fileData);
}
public class CustomFileHandler implements FileHandler {
@Override
public void handle(FileData fileData) {
// do something with fileData
}
}
注册自定义的FileHandler,跟FileProcessor一样,我们也提供了更加简便的注解方式,在自定义的Plugin上通过注解@Handler
来注册FileHandler.
@Handler(implement = CustomFileHandler.class)
@Handler(implement = CustomFileHandler.class, process = Process.TRAVERSE)
public class FlavorCodeOptPlugin extends CommonPlugin<Extension, Context> {...}
这个接口里的方法都对应于Transform API里面的Transform接口里的方法.
每个IPlugin插件可通过复写transformConfiguration接口方法,来自定义一些配置.
比如,在AwemeSpiPlugin里,
@Override
public TransformConfiguration transformConfiguration() {
return new TransformConfiguration() {
@Override
public Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_JARS;
}
};
}
依靠android的transform api registerTransform 只能把你的transform 注册到proguard,dex等内置transform之前,如果想让插件在proguard之后做些事情,或者想在任意的task前后执行你的transform,就需要一些反射hook的手段.ByteX的IPlugin接口提供了两个hookTask方法,只需要复写这拉两个方法(一个标识你是否需要hook,另外一个标识hook的类型),支持在task前或后或者不hook这个task,你的插件就能在紧挨着这个task之前/后执行.(之前版本hookTransformName方案依然可行但被标记成了废弃,目前框架依然会优先使用hookTransform方案实现,但如果hookTask传入的task不是TransformTask或者返回的HookType是after类型则会使用新方案执行)
比如,如果你想让你的插件在proguard之后被执行(即在紧挨dex之前,也就是dexBuilder之前执行),可以这么做:
public class DoAfterProguardPlugin extends CommonPlugin<Extension, Context> {
/**
* eg:dexBuilder
* use {@link #hookTask()} and {@link #hookTask(Task)} instead
*/
@Deprecated
@Nullable
String hookTransformName() {
return "dexBuilder";
}
/**
* 是否使用hook模式运行插件,true表示使用hook模式
*/
boolean hookTask() {
return true;
}
/**
* 是否需要Hook这个task
*
* @param task 被hook的Task
* @return {@link HookType#Before} 该task将被当前插件hook,插件并在task之前处理
* {@link HookType#After} 该task将被当前插件hook,插件并在task之后处理
* {@link HookType#None} 插件不hook这个task
*/
@Nonnull
HookType hookTask(@Nonnull Task task) {
if(task.getName().contains("dexBuilder")){
return HookType.Before;
}
return HookType.None;
}
}
一些插件需要在proguard之后执行(使用上面的hook方案),需要解析mapping来进行混淆或者反混淆。bytex框架被提供了获取mapping接口并提供了比较通用的一些读取、解析mapping的工具,并添加了class名字规范的格式适配器,方便插件开发者能快速在混淆模式下修改字节码。相关的接口如下:
//获取mapping文件
File mappingFile = context.getTransformContext().getProguardMappingFile();
//解析mapping文件
MappingReader mappingReader = MappingReader(mappingFile); //解析器
MappingProcessor mappingProcessor = new FullMappingProcessor();//全部解析
mappingProcessor = new InternalNameMappingProcessor(mappingProcessor);//包装一下,默认类名是.符号分割,适配成Class名字规范(/分割和desc类型)的格式
mappingReader.pump(mappingProcessor);
//默认的提供一个简单解析类
FullInternalNameRetrace retrace = new FullInternalNameRetrace(mappingFile);
如果需要在transform的时候,额外输出一些文件,可以复写beforeTransform方法,调用TransformEngine所提供的addFile方法.
@Override
public void beforeTransform(@Nonnull @NotNull TransformEngine engine) {
super.beforeTransform(engine);
engine.addFile("affinity",new FileData("addFile test".getBytes(),"com/ss/android/ugc/bytex/test.txt"));
}
addFile的第一个参数affinity,可以随意设定.如果两次addFile调用的affinity是一致的话,那么这两个文件将会在同一个输出目录里.
ByteX的插件默认使用增量编译,增量编译时,ByteX框架将回调给插件traverseIncremental方法(两个重载方法,注意:该方法在beforeTraverse前执行),将会传递所有除了NotChanged状态的文件,同时,如果是Jar包,则会解压之后以单个文件(抽象成FileData)的形式传递进来,同时,如果是class文件,则可以像traverse等方法一样传递一个ClassVisitor接收的class信息。两个方法的描述如下:
/**
* 遍历工程中所有的增量文件,不仅仅是class,如果是jar则会解压之后将entry传递进来
* 状态可能为ADD,REMOVE,CHANGED这几种状态,只在增量构建时有效
* <p>
* traverse all incremental file which status is ADD,REMOVE or CHANGED
* file will be uncompressed which is jar input.
* only valid while during incremental build
*
* @param fileData 增量文件
* incremental file
* @param chain 如果是class,则会传递对应的ClassVisitorChain用于加入自定义的ClassVisitor,如果有不是class 则为null
* If it is a class, the corresponding ClassVisitorChain will be passed to add a custom ClassVisitor, or null if there is not a class
*/
default void traverseIncremental(@Nonnull FileData fileData, @Nullable ClassVisitorChain chain) {
}
/**
* 遍历工程中所有的增量文件,不仅仅是class,如果是jar则会解压之后将entry传递进来
* 状态可能为ADD,REMOVE,CHANGED这几种状态,只在增量构建时有效
* <p>
* traverse all incremental file which status is ADD,REMOVE or CHANGED
* file will be uncompressed which is jar input.
* only valid while during incremental build
*
* @param fileData 增量文件,该文件一定是class文件。
* Incremental file, and the file must be a class file.
* @param node 对应的Class解析后的的Tree Node结构
* Tree Node
*/
default void traverseIncremental(@Nonnull FileData fileData, @Nonnull ClassNode node) {
}
如果你的插件支持增量模式,但在处理增量模式中发现无法继续增量处理,则可以在beforeTraverse方法中或之前通过下面的方式向bytex请求全量编译:
context.getTransformContext().requestNotIncremental();
如果你的插件不能支持增量模式,请在插件中声明不使用增量构建,声明方式如下:
public class SourceFileKillerPlugin extends CommonPlugin<SourceFileExtension, SourceFileContext> {
@Nonnull
@Override
public TransformConfiguration transformConfiguration() {
return new TransformConfiguration() {
@Override
public boolean isIncremental() {
//插件默认是增量的,如果插件不支持增量,需要返回false
//The plugin is incremental by default.It should return false if incremental is not supported by the plugin
return true;
}
};
}
...
}
ByteX在关键的生命周期开始和结束时添加对应的钩子代码,用于外部监听到ByteX执行到某些时机时做一些操作.设置监听器的入口在ByteXBuildListenerManager
中,比如:
ByteXBuildListenerManager.INSTANCE.registerByteXBuildListener(yourByteXBuildListener)
ByteXBuildListenerManager.INSTANCE.registerMainProcessHandlerListener(yourMainProcessHandlerListener)
详细的监听事件请查看ByteXBuildListener
和MainProcessHandlerListener
默认的,ByteX内置一个默认的监听器,用于记录编译期间的生命周期事件,相关的数据会在编译完成后记录在app/build/ByteX/build/的两个json中
ByteX可以在各自插件的Extension中配置相关的插件配置,对于ByteX引擎内部,也有一些配置或者开关,这个配置可以让使用者根据需要灵活使用,引擎内部会做不同的处理,配置可以在gradle.properties中添加,相关配置如下:
- bytex.globalIgnoreClassList:需要忽略异常的class配置列表,在处理这些列表中的class如果发生异常,ByteX将内部处理而不是抛出来终止编译。相对于项目根目录的一个相对路径文件,文件中每一行是一个白名单,支持模式匹配
- bytex.enableDuplicateClassCheck:是否检查类重复问题,boolean类型,默认true
- bytex.enableHtmlLog:是否生成html报表,boolean类型,默认true
- bytex.enableRAMCache:是否启用内存缓存用于存储相关的cache,该配置用于优化增量构建,因为加载文件可能需要耗时,boolean类型,默认true。如果是非增量(CI)建议配置为false
- bytex.enableRAMNodesCache:是否缓存Graph中的Node到内存,enableRAMCache为true时生效,boolean类型,默认true。
- bytex.enableRAMClassesCache:是否缓存Graph中的ClassEntity到内存,enableRAMCache为true时生效,boolean类型,默认false。
- bytex.asyncSaveCache:是否使用异步的方式保存cache(非增量的插件不会保存),boolean类型,默认true。
- bytex.verifyProguardConfigurationChanged:获取Proguard的混淆配置是否做校验,用于判断插件获取混淆规则时与Proguard执行时的混淆配置是否相同,boolean类型,默认true。
- bytex.checkIncrementalInDebug:是否禁止不支持增量但enableInDebug为true的插件运行(抛异常),boolean类型,默认false。
- bytex.enableSeparateProcessingNotIncremental:是否自动隔离执行非增量的插件进行单独运行。如果有一个插件不支持增量,ByteX所有插件(非alone)将使用非增量运行,这将大大降低增量构建的速度,开关开启后,支持增量的插件将合在一块执行,不支持增量的插件将独立在一个transform中运行.boolean类型,默认false。
- bytex.${extension.getName()}.alone:是否独立运行某个插件,boolean类型,默认false。
- bytex.useFixedTimestamp:是否固定一个输出文件jar中entity的时间戳(0),这个对增量编译有比较大收益,因为输出内容不变+时间戳不变,后续task可以命中cache(比如DexBuilder)。boolean类型,默认true。
- bytex.forbidUseLenientMutationDuringGetArtifact:在调用GradleEnv.getArtifact时是否禁止使用Lenient方式获取,这个可以规避部分工程因为FileCollection.getFiles()死锁问题.boolean类型,默认false。
- bytex.ASM_API:设置使用ASM传递的API值,目前可以是ASM4、ASM5、ASM6、ASM7、ASM8、ASM9.默认ASM6.
- bytex.enable_gradle_daemon_ignore_classloader_singleton:是否兜底因为Gradle ClassLoader改变导致的ByteX Graph单例内存泄露问题.默认true.
对于新功能,原则上只能从develop分支上拉出来,当需要合入到主干分支,需要先合入到develop分支,后面统一合入到master分支.
对于简单的bugfix,可以直接从master分支拉出修改,往master分支提交mr.当mr被合入后,需要再次提交一个master->develop分支的mr以同步修改.
- module名字
统一全部小写,多个单词之间用"-"(中划线)分割.如果是插件module,请以"-plugin"结尾,如果一个功能既包含插件module,又包含运行时依赖库("java或者library")时,请将这几个module放在ByteX根目录中的同一个目中,避免一个功能分散在byteX的根目录下. - 包名
所有的包名必须以"com.ss.android.ugc.bytex"开始,然后后面一级使用一个代表功能的包名以示区分. - 注释
建议对复杂或者核心的代码编写注释,注释推荐使用英文. - 文档
极力建议在插件写完并上线后,完善文档,文档包含中英两份,均放置到对应插件的module下.sp;原则上aweme上所有插件版本必须统一使用同一个版本号,为了版本不存在歧义,原则上发布正式版本时版本号只能包含四个数字的组合,用"."分隔