热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

关于Dagger2的一些个人理解

写作目的现在基本上有一些规模的项目,都使用了Dagger2,也不是装13,个人感觉也的确是大势所趋,Dagger2的确

写作目的

现在基本上有一些规模的项目,都使用了Dagger2,也不是装13,个人感觉也的确是大势所趋,Dagger2的确有它的优势。借着手上的项目,学习了一下Dagger2,打算用在公司的项目中。今天就来以自己初学者的角度来谈谈dagger2的认识。

问题由来

我是目前是移动端开发者,主要从事的是Android端开发。在andorid开发过程中,按照套路我们会有SharePreferenceManager、DatabaseManager、NetWorkManager、CacheManager等各种Manager,当然你的叫法也可以不同,他们都会存在初始化,考虑到有种可能的初始化顺序,如下图:

我们可能需要先使用Application初始化SpManager(SharePreferenceManager)拿到一些关键数据,从而通过这些数据来初始化DbManager,NWManager或者CacheManager。可以看到我们的各个对象就存在初始化先后的问题,试想如果系统过于复杂,像这种对象初始化有相互依赖的各种关系,想必是非常复杂的;如果是我们用代码去写,也是很繁杂的而且没多大效率的;还有一个问题,看下图:

如果有一天,我们的E对象突然改变了初始化方法,而且很多地方都需要E对象的初始化,那我们是不是很懵,因为有很多地方需要修改,那苦逼的程序员是不是很难受,这也就是我们常说的程序的耦合性,改一处,就要变100处地方。很显然dagger的出现就是为了解决这个问题的。具体怎么解决,我还有想用一张图来说明我的想法:

假设dagger在初始化某对象时,同时需要a b c g四个对象,它就会在容器中寻找(这里的寻找有可能是上一次创建的),找到了就拿来使用,没有找到就需要靠自身去创建了。你可以看到a d g对象都会存在,此时c对象不存在,那么此时的c对象的创建又变成了XObject对象的创建,此时需要找到c对象创建时,需要m对象和p对象,一环一环套下去,然后直到所有的需要的对象都创建完成,然后再递归到需要它需要创建的对象,直到所有的对象初始化完成。明白了这个道理,感觉学习dagger2就不难了。

开始Dagger2


配置

AS , gradle配置:

//dagger2
compile 'com.google.dagger:dagger:2.14.1'
annotationProcessor 'com.google.dagger:dagger-compiler:2.14.1'

新建Student类:

public class Student {
private String name ;
private int score ;
@Inject
public Student(){
this.name = "zhangsan" ;
this.score = 30 ;
}
public Student(String name, int score) {
this.name = name;
this.score = score;
}
@Override
public String toString() {
return "Student{" +
"name='" + name + '\'' +
", score=" + score +
'}';
}
}

和普通java类一致,只是在构造方法上使用了注解@javax.inject.Inject注解,第一步完成;

创建接口StudentComponent,当然这个接口名字你可以乱写,但是为了规范我还是建议你初始化啥对象,就写这个名字,接口很简单:

@Component
public interface StudentComponent {
void inject(SecondActivity activity);
}

我们需要一个@dagger.Component的注解,和一个SecondActivity,这个SecondActivity是我们需要用到的Activity,在这个Activity中我们需要对Student对象进行初始化:
SecondActivity源码:

public class SecondActivity extends AppCompatActivity {
@Inject
Student su;
TextView tv;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_second);
tv = findViewById(R.id.id_tv_content);
}
public void getStudentInfo(View view) {
tv.setText(su.toString());
}
}

在我们需要的初始化的对象上Student也加上@Inject注解,其他的好像真的完事了,就这么简单?当然,你需要将make一下你的project(这里吐槽一下当年使用window系统最终放弃了dagger2无数次,make项目绝对是我不想坚持下去的最大原因,太尼玛花时间了),然后加上一句话:

编译运行,我们可以看到:

的确,程序没有我们想象中报了异常,而是真的被初始化,而且被赋值了。第一次用Dagger2的感觉就是这么神奇。当然这算得上Dagger2最简单的应用场景吧。

不过,距离真正去使用Dagger2还有有一定的差距,因为我们还缺少一样叫module的类。真正的Dagger2是存在三部分的。
1. 对象的实例化部分,相当于我们的new Object(); Dagger提供类似容器功能,提供实例化的功能;这一个叫module;
2. 需要实例化的部分,你实例化的对象需要被用得到,这是个非常抽象的部分,可以使是Activity,也可以是Fragment,或者是任意的Java对象;
3. 连接部分,将实例化的对象和需要用到这些对象的部分连接起来。这一个在dagger中叫Component

好了,有了上面的简单说明,我们来个真正的dagger用法。来个Student2对象,相当于我们的数据:


public class Student2 {
private String name ;
private int score ;
public Student2(String name, int score) {
this.name = name;
this.score = score;
}
@Override
public String toString() {
return "Student2{" +
"name='" + name + '\'' +
", score=" + score +
'}';
}
}

现在我们需要module组件,给我们提供对象实例化的,这个可能稍微有点拐弯:

@Module
public class Student2Module {
@Provides
public String provideName() {
return "lisi" ;
}
@Provides
public int provideScore(){
return 98 ;
}
@Provides
public Student2 provideStudent2(String name , int score){
return new Student2(name,score);
}
}

我们看到了@provides,这是来自Dagger的注解,正如翻译过来,就是给你提供数据量,它提供了一个Student2对象,那么问题来了,那namescore是哪里来的呢?同样我们看到了它提供另外两个方法provideScore()provideName(),同样也被@Provides注解修饰,意思就是我给你提供数据量。

已经有数据可以提供了,那哪里需要这个数据呢?来,看一下我们的Activity,在Activity中有地方需要它:

public class ThirdActivity extends AppCompatActivity {
@Inject //这里使用@Inject 说明我需要被初始化
Student2 stu ;
TextView tv ;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_second);
tv = findViewById(R.id.id_tv_content);
}
//这里是按钮的点击作用,我们需要看一下这个Student2对象是否被初始化
public void getStudentInfo(View v) {
tv.setText(stu.toString());
}
}

数据源和数据去向都有了,那我们就需要一个中间的连接着Component了,好了,我们来造一个Component,如下:

@Component(modules = Student2Module.class) //这就是中间的链接部分,数据源就是这个modules所对应的数据类
public interface Student2Component { //Component是接口,相当于中间件
void inject(ThirdActivity activity); //这个是数据去向,注意不要使用什么BaseActivity,一定要写具体的activity
}

好了,然后我们make(build,reBuild)一下我们的项目,然后在ThirdActivity中添加一句代码:

这个DaggerStudentComponent是Dagger框架自动生成的,它是由你的Component接口然后加上Dagger前缀生成的,作用是连接我们的数据源和数据接收者。点击按钮,可以看到结果:

结果就是我们想要的,也可以看到Student2Module中提供的name和score的确在ActivityStudent2对象中了。是不是很简单呢?是啊,我们的dagger注入框架就好了,这就是注入的效果。

但是你有没有一种感觉,为啥Student类和Student2对象中,Student构造器中使用了@Inject注解,但是Student2构造器中没有使用@Inject呢;为啥Student对应的StudentComponent没有提供获取Student方法,而Student2Component中提供了获取Student2的方法呢?

这就需要我们理解Module作为数据提供者,提供数据也是有先后顺序的,一般顺序如下:
1. 我现在module数据提供者中找有没有提供初始化对象的方法;
2. 我找到了,我就返回该对象;
3. 我没有找到,我就去查看这个对象的构造器是否被@Inject找到
4. 找到了,那么自己执行new Object过程;没有找到,对不起,编译出错,说明你dagger方法使用错误。

所以,综上所述,一个对象的构造器Constructor没有被@Inject修饰,那么在Module中必须有方法提供初始化对象。

好了,我们接下来就说一说工作和生活中用得比较多的对象,也就是关于dagger的标识符问题了。

@Singleton

和Java思想中一致,就是单例的意思。这里的单例依据个人的理解,一个数据接收容器中存在多个数据接收者,那么多个数据接收者指向的对象是同一个。说的自己都有点不信了,举个例子:
定义StudentSingleton数据结构:

@Singleton
public class StudentSingleton {
private String name ;
private int score ;
@Inject //看这里被@Inject修饰了
public StudentSingleton(String name, int score) {
this.name = name;
this.score = score;
}
}

数据提供者StudentSingletonModule:

@Module
public class StudentSingletonModule {
@Provides
public String getName(){
return "tom";
}
@Provides
public int score() {
return 20;
}
}

数据需求者SingletonActivity,可以看得到里面有两个StudentSingleton对象等待被初始化:

public class SingletonActivity extends AppCompatActivity {
@Inject
StudentSingleton singleton1 ;
@Inject
StudentSingleton singleton2 ;
TextView tv;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_second);
tv = findViewById(R.id.id_tv_content);
//build之后 进行对象注入
DaggerStudentSingleComponent.builder().build().inject(this);
}
public void getStudentInfo(View v) {
tv.setText(singleton1 + "\n" + singleton2);
}
}

我们看一下结果:

我们可以看到两个StudentSingleton对象指向的相同的地址,那么说明它们是同一个对象。
同样,我们在另外一个Activity中也需要一个StudentSingleton,如下代码:

public class AnotherStudentSingletonActivity extends AppCompatActivity {
@Inject
StudentSingleton stu ;
TextView tv;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_another_student_singleton);
tv = findViewById(R.id.id_tv_content);
DaggerStudentSingleComponent.builder().build().inject(this);
tv.setText(stu.toString());
}
}

获取的结果为:

由此可见,@Singleton注解并不是我们平常意义上的单例,只是在同一个容器的中,指向同一个对象才可能被称为单例。

当然了,你去掉@Singleton注解,去看看在一个Activity同时获取两个StudentSingleton对象,地址是不是一样呢?答案肯定是不一样的。

初始化对象时,需要获取外部参数

这个在Andorid中需求的比较多,很多时候我们需要一个Context对象,到处都是Context对象,举个例子:
先来个StudentContext对象:

public class StudentContext {
private String name ;
private int score ;
private Context context;
public StudentContext(){}
@Inject //注意@Inject注解修饰的这个构造器
public StudentContext(String name, int score, Context context) {
this.name = name;
this.score = score;
this.context = context;
}
@Override
public String toString() {
return "StudentContext{" +
"name='" + name + '\'' +
", score=" + score +
", context=" + context +
'}';
}
}

在我们的Module中,我们这时需要传入一下个Context对象:

@Module
public class StudentContextModule {
private Context context;
//此时需要你传入一个Context对象进入
public StudentContextModule(Context context) {
this.context = context;
}
@Provides
public String provideName() {
return "jerry" ;
}
@Provides
public int provideScore() {
return 20 ;
}
//这里提供一个Context对象方法
@Provides
public Context provideContext() {
return context;
}
}

我们桥接对象为:

@Component(modules = StudentContextModule.class)
public interface StudentContextComponent {
void inject(StudentContextActivity activity);
}

make一下我们的项目,在数据接收容器StudentContextActivity中,这么写:

public class StudentContextActivity extends AppCompatActivity {
@Inject
StudentContext sContext;
TextView tv ;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_second);
tv = findViewById(R.id.id_tv_content);
//这里我们就需要提供一个StudentContextModule对象
//是的,这个对象是我们New的
DaggerStudentContextComponent.builder().studentContextModule(new StudentContextModule(this)).build().inject(this);
}
public void getStudentInfo(View v) {
tv.setText(sContext.toString());
}
}

结果为:

你可以看到,我们的Context对象也被传入进去了,其实就是我们的StudentContextModulenew时传入的this写入的。

依赖Module

这个意义,就相当我们每个应用中只有一个Application,我们的Activity可以依赖于这个Application,然后可以Toast,SharePreference我们想要的东西。那么就来一个AppModule,全局来一个Context:

@Module
public class AppModule {
private Context context ;
public AppModule(Context context) {
this.context = context;
}
@Provides
public Context provideContext() {
return context;
}
@Provides
public String provideGlobalName() {
return "globalName" ;
}
}

那么它对应的Component为:

@Component(modules = AppModule.class)
public interface AppComponent {
/** * 向下提供Context * @return */
Context getContext();
/** * 向下提供Name * @return */
String getStringName();
}

这里有一点需要声明一下,APPComponent将要被依赖,那么它需要暴露出提供初始化对象的方法,然后依赖它的Component将找不到。
好了,基类被依赖的APPComponent已经有了,现在来一个ActivityComponent,它提供了StudentContext对象方法和score对象方法。

@Module
public class ActivityModule {
@Provides
public StudentContext provideStudentContext(Context context,String name , int score) {
return new StudentContext(name,score,context);
}
@Provides
public int provideActivityScore() {
return 78 ;
}
}

那么重点来了,ActivityComponent将会依赖AppComponent,AppComponent将会提供Context对象方法和name对象方法,注意dependencies关键字,它指向了依赖的对象,同时依赖的对象也是一个Component

@Component(modules = ActivityModule.class, dependencies = AppComponent.class)
public interface ActivityComponent {
void inject(DependencyActivity activity);
}

DependencyActivity中:

public class DependencyActivity extends AppCompatActivity {
@Inject
StudentContext sContext;
TextView tv ;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_second);
tv = findViewById(R.id.id_tv_content);
//先获取AppComponent对象
AppComponent appComponent = DaggerAppComponent.builder().appModule(new AppModule(this)).build();
//ActivityComponent对象将会依赖于AppComponent
DaggerActivityComponent.
builder().
appComponent(appComponent).
build().inject(this);
}
public void getStudentInfo(View v) {
tv.setText(sContext.toString());
}
}

同样我们打印结果为:

@Name注解

主要解决同一个容器中,同时又多个相同对象需要被注入,如果你不做任何指导,那么dagger就不确定数据注入的唯一性(数据提供者 -> 数据接收者),此时有一种方法就是使用@Name注解:
还是定义一个StudentForNameAt对象,可以看到它有一个namecontext属性

public class StudentForNameAt {
private String name ;
private Context context;
public StudentForNameAt(String name) {
this.name = name;
}
public StudentForNameAt(Context context) {
this.context = context;
}
@Override
public String toString() {
return "StudentForNameAt{" +
"name='" + name + '\'' +
", context=" + context +
'}';
}
}

那么NameAtModule需要这么写:

@Module
public class NameAtModule {
private Context context ;
private String name ;
public NameAtModule(Context context,String name) {
this.context = context;
this.name = name;
}
@Provides
public Context provideContext() {
return context;
}
@Named("context") //这里使用"contex"标识
@Provides
public StudentForNameAt provideStudentForNameAtContext() {
return new StudentForNameAt(context);
}
@Named("name")//这里使用"name"标识
@Provides
public StudentForNameAt provideStudentForNameAtName(){
return new StudentForNameAt(name);
}
}

最后,在我们的NameAtActivity中,同时也需要@Name("name")@Name("context")标识对象的注入:

public class NameAtActivity extends AppCompatActivity {
@Named("name")//这里标识
@Inject
StudentForNameAt at1 ;
@Named("context") //这里标识
@Inject
StudentForNameAt at2 ;
TextView tv;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_second);
tv = findViewById(R.id.id_tv_content);
DaggerNameAtComponent.builder().nameAtModule(new NameAtModule(this,"hello world")).build().inject(this);
}
public void getStudentInfo(View v) {
tv.setText(at1.toString() + "\n" + at2.toString());
}
}

获取结果为:

可以看到,我们的两个StudentForNameAt对象分别被注入了NameContext

当然这只是其中一种方法,还有一种方法就是自定义的Qualifier标识符。

@Qualifier 自定义标识符

@Qualifier标识符也是为了解决上面的问题,我们来同时定义一下两个注解标识符@StudentForNameStudentForScore:


@Qualifier //dagger Qualifier标识符
@Retention(RetentionPolicy.RUNTIME) //标识为运行时状态
public @interface StudentForName {
}

@Qualifier
@Retention(RetentionPolicy.RUNTIME)
public @interface StudentForScore {
}

定义一个StudentQualifier对象,如下:

public class StudentQualifier {
private String name ;
private int score ;
public StudentQualifier(){ //空的构造器 name是Tom score是77
this.name = "Tom";
this.score = 77 ;
}
public StudentQualifier(String name , int score) {
this.name = name;
this.score = score;
}
@Override
public String toString() {
return "StudentQualifier{" +
"name='" + name + '\'' +
", score=" + score +
'}';
}
}

数据提供者Module为:

@Module
public class StudentQualifierModule {
@Provides
public String provideName(){
return "zhangsan" ;
}
@Provides
public int provideScore(){
return 22 ;
}
@StudentForScore //默认构造器被@StudentForScore修饰
@Provides
public StudentQualifier provideStudent(){
return new StudentQualifier();
}
@StudentForName //有参构造器被@StudentForName修饰
@Provides
public StudentQualifier provideStudentWithParams(String name , int score){
return new StudentQualifier(name,score);
}
}

然后在数据QualifierActivity接收器中:

public class QualifierActivity extends AppCompatActivity {
TextView tv;
@StudentForName //被@StudentForName修饰,按道理应该是调用了有参构造器
@Inject
StudentQualifier qualifier1 ;
@StudentForScore//这个就是无参数的
@Inject
StudentQualifier qualifier2 ;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_second);
tv = findViewById(R.id.id_tv_content);
DaggerStudentQualifierComponent.builder().build().inject(this);
}
public void getStudentInfo(View v) {
tv.setText(qualifier1.toString() + "\n" + qualifier2.toString());
}
}

我们猜测一下结果,qualifier1应该为zhangsan22;qualifier2应该为这Tom77,结果为:

结果一致。

@PerApp and @PerActivity

我也不知道为什么,上github上看dagger写的代码,都少不了这个两个注解。如果理解了@Singleton注解的意义,那么@PerApp@PerActivity意义就差不多。上面已经说过,一个App中只有一个Applicataion,那么我们可以定义一个注解@PerApp
生命周期可以与Application一致,那么就可以理解为@PerApp修饰的方法,提供的对象在全局范围是单例的;如果定义一个@PerActivity,那么@PerActivity修饰的方法返回的对象,生命周期是与Activity是一致的。举个例子吧:
定义一个@AndroidPerApp

@Scope //这里是@Scope 注意不是@Qualifier
@Documented //文档标记
@Retention(RetentionPolicy.RUNTIME)
public @interface AndroidPerApp {
}

再定义一个Module,此时有个方法被@AndroidPerApp修饰:

@Module
public class AndroidPerAppModule {
private Context context;
public AndroidPerAppModule(Context context) {
this.context = context ;
}
@Provides
@AndroidPerApp //标记该方法只产生一个实例 该实例的生命周期与绑定的Context生命周期一致
public Context provideContext(){
return context;
}
}

此时我们的AndroidPerAppComponent

@AndroidPerApp//AndroidPerAppModule有方法被@AndroidPerApp修饰 那么此时AndroidPerAppComponent也要被@AndroidPerApp修饰
@Component(modules = AndroidPerAppModule.class)
public interface AndroidPerAppComponent {
/** * 向下提供Context * @return */
Context getContext();
}

此时,既然是AppComponent,那么需要与App绑定了:

public class MyApp extends Application{
//这里使用静态的 因为MyApp全局就一个
static AndroidPerAppComponent component ;
@Override
public void onCreate() {
super.onCreate();
component = DaggerAndroidPerAppComponent.builder().androidPerAppModule(new AndroidPerAppModule(this)).build();
}
public static AndroidPerAppComponent getAppComponent(){
return component;
}
}

那么我们可以全局调用MyApp.getAppComponent()了,这个等会再用,现在再来顶一个@PerActivity:


@Scope
@Documented
@Retention(RetentionPolicy.RUNTIME)
public @interface AndroidPerActivity {
}

AndroidPerActivityModule中,Activity生命周期范围内,返回的数据是单例的,其实是和@Singleton作用是一致的,它并没有像市面上说的,具有和Activity相同生命周期的说法。


@Module
public class AndroidPerActivityModule {
@AndroidPerActivity
@Provides
public Student provideStudent(){
return new Student();
}
}

然后最重要的是PerActivityComponent了:

@AndroidPerActivity
@Component(modules = AndroidPerActivityModule.class,
dependencies = AndroidPerAppComponent.class)
public interface AndroidPerActivityComponent {
void inject(AndroidAnnotationActivity activity);
}

AndroidAnnotationActivity中:

public class AndroidAnnotationActivity extends AppCompatActivity {
@Inject
Student st1 ;
@Inject
Student st2;
TextView tv ;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_second);
tv = findViewById(R.id.id_tv_content);
//直接使用App.getAppComponent()获取全局的AppComponent对象
DaggerAndroidPerActivityComponent.builder().androidPerAppComponent(MyApp.getAppComponent()).build().inject(this);
}
public void getStudentInfo(View v) {
//Toast.makeText(this, "---->>" + MyApp.getAppComponent(), Toast.LENGTH_SHORT).show();
tv.setText(st1.toString() + "--\n--" + st2.toString());
}
}

我们此时开看一下st1st2的结果:

可以看出,它们在AndroidAnnotationActivity范围内是单例的,这个和@Singleton注解是一致的。工作中我们会用到@PerApp,它解决了我们全局需要Context的问题。

Lazy 和 Provider

根据词义,Lazy相当于我们懒加载机制,调用了本方法才会去加载,没有调用就不加载;Provider很类似@Provides注解,但是它在的含义是每次调用都会去重复调用Module@Provides修饰的方法。举个例子:
定义一个对象类型StudentOther:

public class StudentOther {
private String name ;
private int score ;
private String address ;
//初始化过程中会使用random对象 以此来模拟是否为重复创建
public StudentOther(Random random) {
this.name = "zhansgan" + random.nextDouble() ;
this.score = random.nextInt(200);
this.address = "shanghai" + random.nextGaussian();
}
@Override
public String toString() {
return "StudentOther{" +
"name='" + name + '\'' +
", score=" + score +
", address='" + address + '\'' +
'}';
}
}

定义一个OtherActivityModule对象,提供了Random方法,还有两个@StudentForName@StudentForScore注解修饰的方法:

@Module
public class OtherActivityModule {
public Random random ;
public OtherActivityModule(){
random = new Random();
}
@Provides
public Random provideRandom(){
return random;
}
@StudentForName
@Provides
public StudentOther provideStudentOther(Random random){
return new StudentOther(random);
}
@StudentForScore
@Provides
public StudentOther provideStudentOtherWidth(Random random){
return new StudentOther(random);
}
}

在数据接收器OtherActivity中,如下:

public class OtherActivity extends AppCompatActivity {
@StudentForScore
@Inject
Lazy other;
@StudentForName
@Inject
Provider other2 ;
TextView tv;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_second);
tv = findViewById(R.id.id_tv_content);
DaggerOtherActivityComponent.builder().build().inject(this);
}
public void getStudentInfo(View v){
StudentOther innerOther = other.get(); //调用该方法时才会去创建
StudentOther innerOther2 = other2.get(); //调用该方法时才会去创建,但是每次都会重新加载Module中的方法 返回值有可能相同 也有可能不同
tv.setText(innerOther.toString() + "---\n---" + innerOther2.toString());
}
}

我们来看一下结果:

我们可以看到innerOther始终是同一个值,而innerOther2却一直是变化的,说明每次调用Provide.get()时都去刷新了

@StudentForName
@Provides
public StudentOther provideStudentOther(Random random){
return new StudentOther(random);
}

方法。

好了,基本上总结的差不多了,文章写了很久。稍后会把代码上传一下,也算是给自己的一个交代了吧。
基本上样式如下:


推荐阅读
author-avatar
幽灵-玲
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有