编写自定义View并发布到JCenter

96
阿_希爸
2016.11.20 22:15* 字数 2651

很多开发者开发了多个项目之后,都会积累一些自定义的View或者工具类,每次开始一个新项目的时候一般都是一个包复制过去,其实我们可以将自己常用的一些东西封装起来封装到Library里面,然后将他发布到jCenter,以后在做项目直接在gradle中添加依赖就可以了。

本文我们来编写一个自定义View,并将它发布到JCenter。

1 自定义View

先来看下目标View的运行结果。

很简单的一个View,三个圆角方形,依次增大缩小并改变透明度,如果你看着眼熟的话,可能你经常看Tumblr,恩,我就是cos的Tumblrloading,经常逛Tumblr觉得UI设计还是挺好的,至于我逛Tumblr都看些啥,老司机们都懂的。

1.1 添加Library

首先我们来创建项目,new Project命名为BlockLoading,包名最好加上.sample做后缀,即xxx.xxx.blockloading.sample这样的结构。

接着给项目添加Library,File -> New -> New Module,如图

选择Android Library -> Next

Library名称为BlockLoading,包名xxx.xxx.blockloading,所以你现在理解为什么开始创建项目的时候包名后面要添加后缀了吧,因为这个Library才是我们要发布的内容。

目前为止项目就创建好了,我将app改名为sampleBlockLoading改名为library,你知道什么意思就好,不必改成一样的。

在library下添加一个BlockLoading文件,内容如下:

package com.axiba.blockloading;

import android.content.Context;
import android.util.AttributeSet;
import android.view.View;

public class BlockLoading extends View {

    public static final String TAG = BlockLoading.class.getSimpleName();

    public BlockLoading(Context context) {
        super(context);
    }

    public BlockLoading(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public BlockLoading(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }
}

修改sample项目的build.gradle文件,在dependencies中添加compile project(':library'),例如:

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile project(':library')
    compile 'com.android.support:appcompat-v7:23.4.0'
}

现在我们可以在sample中引用library的文件了,在activity_main.xml中引用BlockLoading:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin">

    <com.axiba.blockloading.BlockLoading
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

当然这个控件什么都没有写,所以什么也不会展示的,只是构建好了项目的框架而已。

如需查看项目代码 --> 代码地址:

https://github.com/tough1985/BlockLoading

选择Tag -> step1

1.2 绘制初始状态的Block

先分析下目标View,三个圆角滑块,慢慢长高,由比较透明变的没有那么透明。那么我们先来绘制三个初始状态的Block。

由于目标view是三个方块,也就是说第二个方块的中心应该和view的中心重合,所以我们从中心点出发,分别找到三个滑块的left和top坐标。

中间滑块的坐标为:

block2Top = height / 2 - blockHeight / 2;
block2Left = width / 2 - blockWidth / 2;

三个方块目前都是静态的,所以top坐标都一样。由图分析我们可以分别得到另外两个方块的坐标:

block1Left = block2Left - blockSpace - blockWidth;
block3Left = block2Left + blockSpace + blockWidth;

得到位置关系后我们现在可以绘制三个方块了。

定义以下变量:

    //画笔
    private Paint paint;

    //默认模块颜色
    private int defaultColor = Color.rgb(200, 200, 200);

    //绘制区域的宽高
    private int width;
    private int height;

    //方块的间距
    private float blockSpace;

    //方块的宽和高
    private float blockWidth;
    private float blockHeight;

    //方块的顶坐标
    private float blockTop;

    //方块的左坐标
    private float block1Left;
    private float block2Left;
    private float block3Left;

添加一个初始化方法init代码如下:

    //初始化参数
    private void init(){

        //创建画笔
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        //设置画笔颜色
        paint.setColor(defaultColor);
        //设置透明度
        paint.setAlpha(100);

        width = getMeasuredWidth();
        height = getMeasuredHeight();

        blockSpace = 25;
        blockWidth = 100;
        blockHeight = 110;

        blockTop = (height - blockHeight) / 2;

        block2Left = (width - blockWidth) / 2;
        block1Left = block2Left - blockSpace - blockWidth;
        block3Left = block2Left + blockSpace + blockWidth;
    }

这里暂时先将参数写死,那么调用的时候最好使用match_parent模式来调用view。

init方法应该在哪调用呢?
由于在init中我们要获取view的width和height,那么在构造方法用调用肯定是不行的,因为这时候控件还没有经过measure,没有办法获取控件的width和height。

一个View的绘制要经过measure -> layout -> draw。在measure之后我们就能获取view的width和height了,其实在onMeasure方法之后就已经能够准确获取view的width和height了,那么init方法可以在onLayout中调用也可以在onDraw中调用,不过初始化方法更像是在给这个view做布局,那么我们就在onLayout中调用,如果在onDraw中调用的话,可以引用一个变量来标注是否已经初始化,以避免重复调用init

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    super.onLayout(changed, left, top, right, bottom);
    init();
}

然后我们要在onDraw中绘制方块,使用canvas.drawRoundRect可以来绘制一个圆角方块,不过要21版本以上才支持,由于我们只是演示,所以暂时使用这个方法。代码如下:

@TargetApi(Build.VERSION_CODES.LOLLIPOP)
    @Override
    protected void onDraw(Canvas canvas) {

        //绘制方块
        canvas.drawRoundRect(block1Left, blockTop, block1Left + blockWidth, blockTop + blockHeight, 5, 5, paint);
        canvas.drawRoundRect(block2Left, blockTop, block2Left + blockWidth, blockTop + blockHeight, 5, 5, paint);
        canvas.drawRoundRect(block3Left, blockTop, block3Left + blockWidth, blockTop + blockHeight, 5, 5, paint);
    }

运行结果如下:

如需查看项目代码 --> 代码地址:

https://github.com/tough1985/BlockLoading

选择Tag -> step2

1.3 按照比例分配尺寸

现在的尺寸是写死的,实际当中肯定不可能是这样的,首先我们要分配一下宽高的比例,参考下图:

其中黄色部分是padding的部分,红色的部分是我们绘制的区域,那么width和height需要减掉padding的部分。

width = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
height = getMeasuredHeight() - getPaddingTop() - getPaddingBottom();

按照图上的分配,初始化相应的变量如下:

blockSpace = width * 0.05f;
blockWidth = width * 0.2f;
blockMinHeight = blockWidth * 1.1f;
blockMinHeight = blockWidth * 1.5f;

blockHeight = blockMinHeight;

当然比例你可以自由分配。

接下来为了让wrap_content生效,我们要给控件添加一个最小的宽度和最小的高度:

private static final int DEFAULT_MIN_WIDTH = 200;   //默认宽度
private static final int DEFAULT_MIN_HEIGHT = 100;  //默认高度

并重写onMeasure方法

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSpecSize = MeasureSpec.getMode(heightMeasureSpec);

        if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(DEFAULT_MIN_WIDTH, DEFAULT_MIN_HEIGHT);
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(DEFAULT_MIN_WIDTH, heightSpecSize);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthSpecSize, DEFAULT_MIN_HEIGHT);
        }
    }

这段代码解释起来就是如果宽或者高定义为wrap_content,那么就将对应的值设为我们默认的最小值,单位是px。如果你想具体了解measure的过程,推荐看《Android开发艺术探索》这本书。

现在我们来修改layout文件,将width和height修改为wrap_content模式,并运行,可以得到下图的效果:

如需查看项目代码 --> 代码地址:

https://github.com/tough1985/BlockLoading

选择Tag -> step3

1.4 让方块动起来

让方块动起来其实就是更改他的高度和透明度,重新绘制。

目标效果是从小变大,到最大之后在变小的反复过程,那么我需要两个常亮分别表示变大和变小两个阶段。然后使用变量标记block的当前状态。

private static final int BLOCK_STATE_IN = 1;       //增加
private static final int BLOCK_STATE_DE = 2;       //减小

//状态 增加或减小
private int blockState;

定义一个常量,表示由最小到最大经历的帧数,并定义一个变量来记录每次block高度的变化量,最后再定义一个变量来记录当前的帧数。

private static final int STEP_NUM = 15;    //总帧数,从0开始为第一帧
private float blockStepHeight;  //每次变化的高度
private int blockStep;  //当前帧

blockStepHeight可以在得到最大高度和最小高度后初始化:

//初始化参数
private void init(){

    ......

    //初始化方块起始状态为变大
    blockState = BLOCK_STATE_IN;

    //初始化每一帧height变化量
    blockStepHeight = (blockMaxHeight - blockMinHeight) / STEP_NUM;

    blockStep = 0;
}

最后来修改onDraw方法,内容如下:

protected void onDraw(Canvas canvas) {

    if (blockState == BLOCK_STATE_IN) {
        //如果是变大状态,帧数+1
        blockStep++;
    } else {
        //如果是变小状态,帧数-1
        blockStep--;
    }

    //根据当前帧数获得高度
    blockHeight = blockMinHeight + blockStep * blockStepHeight;
    //获取顶点坐标
    blockTop = (height - blockHeight) / 2;
    
    //绘制方块
    canvas.drawRoundRect(block1Left, blockTop, block1Left + blockWidth, blockTop + blockHeight, 5, 5, paint);
    canvas.drawRoundRect(block2Left, blockTop, block2Left + blockWidth, blockTop + blockHeight, 5, 5, paint);
    canvas.drawRoundRect(block3Left, blockTop, block3Left + blockWidth, blockTop + blockHeight, 5, 5, paint);

    if (blockStep >= STEP_NUM) {
        //如果帧数已经是最后一帧,状态改为变小状态
        blockState = BLOCK_STATE_DE;
    } else if(blockStep <= 0){
        //如果帧数已经是第一帧,状态改为变大状态
        blockState = BLOCK_STATE_IN;
    }

    //触动刷新
    postInvalidate();

}

代码比较简单,注释应该也能看懂,就不多做解释了。为了运行效果更好,还是将view的width和height改为match_parent。运行结果如下:

可以看到三个block一起动了起来。

如需查看项目代码 --> 代码地址:

https://github.com/tough1985/BlockLoading

选择Tag -> step4

1.5 最后的封装

最后要做的事其实特别简单,我们只要给三个block初始化不同的step就可以实现目标的效果。至于透明度的变化和高度的变化道理是一样的,最后添加了一个小功能,就是为block设置颜色,这里就直接给出最后封装好的代码,供大家参考。

package com.axiba.blockloading;

import android.annotation.TargetApi;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.os.Build;
import android.util.AttributeSet;
import android.view.View;

/**
 * Created by axiba on 2016/11/17.
 */

public class BlockLoading extends View {

    public static final String TAG = BlockLoading.class.getSimpleName();

    //画笔
    private Paint paint;

    //默认模块颜色
    private int defaultColor = Color.rgb(200, 200, 200);

    //block颜色
    private int blockColor;

    //绘制区域的宽高
    private int width;
    private int height;

    private static final int DEFAULT_MIN_WIDTH = 200;   //默认宽度
    private static final int DEFAULT_MIN_HEIGHT = 100;  //默认高度

    private static final int MIN_ALPHA = 100;      //透明度最小值
    private static final int MAX_ALPHA = 200;      //透明度最大值

    private static final int BLOCK_STATE_IN = 1;       //增加
    private static final int BLOCK_STATE_DE = 2;       //减小

    private static final int STEP_NUM = 15;    //总帧数,从0开始为第一帧
    private float blockStepHeight;  //每次变化的高度
    private int blockStepAlpha;     //每次透明度变化的量
//    private int blockStep;  //当前帧

    //当前帧
    private int block1Step;
    private int block2Step;
    private int block3Step;
    private int[] blocksStep;

    //方块的间距
    private float blockSpace;

    //方块的宽和高
    private float blockWidth;
//    private float blockHeight;

    private float blockMinHeight;   //最小的高度
    private float blockMaxHeight;   //最大的高度

//    //方块的顶坐标
//    private float blockTop;

    //方块的左坐标
    private float block1Left;
    private float block2Left;
    private float block3Left;
    private float[] blocksLeft;

    //状态 增加或减小
//    private int blockState;
    private int block1State;
    private int block2State;
    private int block3State;
    private int[] blocksState;

    //圆角半径
    private float r;

    public BlockLoading(Context context) {
        super(context);
    }

    public BlockLoading(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public BlockLoading(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.loading);
        //获取block_color
        blockColor = a.getColor(R.styleable.loading_block_color, defaultColor);
    }

    //初始化参数
    private void init(){

        //创建画笔
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        //设置画笔颜色
//        paint.setColor(defaultColor);
        paint.setColor(blockColor);
        //设置透明度
        paint.setAlpha(100);

        width = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
        height = getMeasuredHeight() - getPaddingTop() - getPaddingBottom();

        //为了保持比例
        if ((width / 2) > height) {
            width = height * 2;
        }

//        blockSpace = 25;
//        blockWidth = 100;
//        blockHeight = 110;
        blockSpace = width * 0.05f;
        blockWidth = width * 0.2f;
        blockMinHeight = blockWidth * 1.1f;
        blockMaxHeight = blockWidth * 1.5f;

        block2Left = (width - blockWidth) / 2;
        block1Left = block2Left - blockSpace - blockWidth;
        block3Left = block2Left + blockSpace + blockWidth;

        //初始化方块起始状态为变大
//        blockState = BLOCK_STATE_IN;
        block1State = BLOCK_STATE_IN;
        block2State = BLOCK_STATE_IN;
        block3State = BLOCK_STATE_IN;

        //初始化每一帧height变化量
        blockStepHeight = (blockMaxHeight - blockMinHeight) / STEP_NUM;
        blockStepAlpha = Math.round((MAX_ALPHA - MIN_ALPHA) / STEP_NUM);

//        blockStep = 0;
        //为每个block初始化不同的起点
        block1Step = 14;
        block2Step = 7;
        block3Step = 0;

        r = blockWidth / 8;

        blocksStep = new int[]{block1Step, block2Step, block3Step};
        blocksState = new int[]{block1State, block2State, block3State};
        blocksLeft = new float[]{block1Left, block2Left, block3Left};

    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSpecSize = MeasureSpec.getMode(heightMeasureSpec);

        if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(DEFAULT_MIN_WIDTH, DEFAULT_MIN_HEIGHT);
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(DEFAULT_MIN_WIDTH, heightSpecSize);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthSpecSize, DEFAULT_MIN_HEIGHT);
        }
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        init();
    }

    @Override
    protected void onDraw(Canvas canvas) {

        for (int i = 0; i < 3; i++) {
            drawBlock(canvas, i);
        }

        //触动刷新
        postInvalidate();

    }

    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    private void drawBlock(Canvas canvas, int index){
        float blockTop;
        float blockLeft = blocksLeft[index];
        float blockHeight;
        int blockAlpha;

        if (blocksState[index] == BLOCK_STATE_IN) {
            //如果是变大状态,帧数+1
            blocksStep[index]++;
        } else {
            //如果是变小状态,帧数-1
            blocksStep[index]--;
        }

        //根据当前帧数获得高度
        blockHeight = blockMinHeight + blocksStep[index] * blockStepHeight;

        //根据当前帧数获得透明度
        blockAlpha = MIN_ALPHA + blocksStep[index] * blockStepAlpha;

        //保持height值在最大值和最小值范围之内
        blockHeight = Math.min(blockMaxHeight, Math.max(blockMinHeight, blockHeight));

        //保持alpha值在最大值和最小值范围之内
        blockAlpha = Math.min(MAX_ALPHA, Math.max(MIN_ALPHA, blockAlpha));

        //获取顶点坐标
        blockTop = (height - blockHeight) / 2;

        //设置透明度
        paint.setAlpha(blockAlpha);

        //绘制方块
        canvas.drawRoundRect(blockLeft, blockTop, blockLeft + blockWidth, blockTop + blockHeight, r, r, paint);


        // -5 是为了增加一个停顿感
        if (blocksStep[index] >= STEP_NUM) {
            //如果帧数已经是最后一帧,状态改为变小状态
            blocksState[index] = BLOCK_STATE_DE;
        } else if(blocksStep[index] <= 0 - 5){
            //如果帧数已经是第一帧,状态改为变大状态
            blocksState[index] = BLOCK_STATE_IN;
        }
    }
}

别忘了添加attr.xml文件,代码如下:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="loading">
        <attr name="block_color" format="color"/>
    </declare-styleable>
</resources>

如需查看项目代码 --> 代码地址:

https://github.com/tough1985/BlockLoading

选择Tag -> step5

2 发布到Maven

发布组件的过程大概如下:

  1. 生成Maven需要的pom文件
  2. 打包源码和doc
  3. 上传到Bintray
  4. 发布到JCenter

2.1 添加Maven插件和Bintray插件

编辑项目的根目录下的build.gradle

buildscript {
    ......
    dependencies {
        .......
        classpath 'com.github.dcendents:android-maven-gradle-plugin:1.5'
        classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.7'
    }
}

2.2 应用插件

修改库模块下的build.gradle,添加如下代码即可使用上面的插件

apply plugin: 'com.github.dcendents.android-maven'
apply plugin: 'com.jfrog.bintray'

这两个插件一个帮你生成pom文件,一个将你的打包文件上传到bintray。

2.3 添加pom文件相关内容

group = 'com.axiba.blockloading'
version = '1.0'

在maven中引用一个插件的代码是这样的:

<dependency>
  <groupId>com.axiba.blockloading</groupId>
  <artifactId>library</artifactId>
  <version>1.0</version>
  <type>pom</type>
</dependency>

同样的插件在gradle中引用是这样的:

compile 'com.axiba.blockloading:library:1.0'

也就是groupId:artifactId:version的形式。
那么我们指定了gruopIdversionartifactId就是项目的名称,这里是library

接着指定一些pom相关的内容:

def siteUrl = 'https://github.com/tough1985/BlockLoading'
def gitUrl = 'https://github.com/tough1985/BlockLoading.git'

//用户生成Maven的pom文件
install {
    repositories.mavenInstaller {
        pom {
            project {
                packaging 'aar'
                name 'BlockLoading'
                url siteUrl

                licenses {
                    license {
                        name 'The Apache Software License, Version 2.0'
                        url 'http://www.apache.org/licenses/LICENSE-2.0.txt'
                    }
                }

                developers {
                    developer {
                        id 'axiba'
                        name 'axiba'
                        email '66903041@qq.com'
                    }
                }

                scm {
                    connection gitUrl
                    developerConnection gitUrl
                    url siteUrl
                }
            }
        }
    }
}

2.4 生成源文件JAR和Javadoc JAR

task sourcesJar(type: Jar) {
    from android.sourceSets.main.java.srcDirs
    classifier = 'sources'
}

task javadoc(type: Javadoc) {
    source = android.sourceSets.main.java.srcDirs
    classpath += project.files(android.getBootClasspath().join(File.pathSeparator))
}

task javadocJar(type: Jar, dependsOn: javadoc) {
    classifier = 'javadoc'
    from javadoc.destinationDir
}

现在你可以在右侧的Gradle侧边栏中找到:library -> Tasks -> other -> install,运行成功后可以在library项目下的build文件夹下看到生成的相关文件。

2.5 使用Bintray

首先到Bintray官网上注册个用户,直接用github的账号就行。

点击这里添加一个仓库。

选择public;仓库名称你可以随意,但是要记住名字,后面要用;type选择Maven。

在library目录下添加一个文件名为bintrary.properties
并在.gitignore中添加bintray.properties

bintrary.properties的内容如下:

bintray_user=tough1985
bintray_key=you_bintray_key
bintray_userOrg=axiba
bintray_repo=maven

这4个值在哪拿呢?看图!

repo就是创建仓库时的仓库名称,点击EditProfile


user和userOrg

key

最后在build.gradle文件中添加代码:


artifacts {
    archives javadocJar
    archives sourcesJar
}

//加载配置文件
def bintrayProperties = new Properties()
bintrayProperties.load(new FileInputStream(file("bintray.properties")))

bintray {
    user = bintrayProperties['bintray_user']
    key = bintrayProperties['bintray_key']
    configurations = ['archives']
    pkg {
        userOrg = bintrayProperties['bintray_userOrg']
        repo = bintrayProperties['bintray_repo']
        name = 'com.axiba.blockloading'
        websiteUrl = siteUrl
        vcsUrl = gitUrl
        licenses = ['Apache-2.0']
        publish = true
    }
}

运行下图的Task


运行成功之后,到Bintray上你的仓库中可以找到对应的项目。

现在你可以新建一个项目,通过maven来引入这个项目了。

编辑根项目的build.gradle

allprojects {
    repositories {
        jcenter()
        maven {
            url 'https://axiba.bintray.com/maven'  //你的Bintray上的manve地址
        }
    }
}

在app中的build.gradle添加依赖:

compile 'com.axiba.blockloading:library:1.0'

2.6 同步到JCenter

想要同步到JCenter的话只要在Bintray中点击个按钮并编辑下内容就可以了,看图。


然后输入一些项目的相关描述就可以了。JCenter是需要审核的,审核好了你就不用在引用自己的仓库了。

如需查看项目代码 --> 代码地址:

https://github.com/tough1985/BlockLoading

选择Tag -> step6

结语

本文比较简单,并没有什么技术难度,相关的内容其实网上也有很多,不过我还是踩到了坑。如果你在运行gradle相关task出错的话,我建议你使用命令行的方式运行task,可以看到清晰的出错原因,方便解决问题。还有就是可以运行一下clean这个task来做一下清理在去运行其他的task。

利用这个功能可以封装一些自己常用的工具类,application,activity,fragment等相关内容,也可以自己封装一些mvp的基类,方便快速开发新项目,让你在新项目开发的过程中把精力集中在业务逻辑上,避免重复的搭建相关框架。

阿_希爸的技术博文