当前位置:   article > 正文

jetpack组件之Room原理分析_room jetpack

room jetpack

前言

Google在没有推出Room组件之前,如果我们想要操作数据库,android是采用sqlite作为数据库存储。有使用过sqlite的读者应该十分清楚,sqlite的使用相当的复杂,而且代码写起来既繁琐又容易出错。因此,各大开源社区便推出了各种ORM库来简化sqlite的操作。而作为Google的亲儿子Room也是对sqlite的一层抽象。它使用了APT机制,通过注解的方式在编译阶段生成sqlite代码,使开发者不需要写冗杂的sqlite代码,提高开发效率。

基本使用

想要了解一个东西的用法,我们首先要会用它。sqlite的用法这里不多介绍了,如果不会使用的读者可以参考下面这边博客:android使用SQLite。sqlite的用法是繁琐了点,大家多写几遍就熟悉了。
我们重点看下room的用法。
在这里插入图片描述

从上面这幅图中可以清楚的看到,整个room的操作流程:room对象持有了dao对象的引用,dao对象则持有了entity对象的引用,entity对象则被ui(一般是activity、fragment)持有。说的通俗一点,就是Room类中有一个dao类型的成员变量,而dao类中则有一个entity类型的成员变量,ui中则有一个entity的成员变量。
那么room、dao、entity都是些什么东西呢?在代码中又是怎么表现出来的呢?

  • Entity:一个Entity 对应于数据库中的一张表。Entity 类是 Sqlite 表结构
    对 Java 类的映射,在Java 中可以被看作一个Model 类。也就是Entity就是一个java的model类,只不过这个类被加入了一个Entity注解。
  • Dao:其实就是一个接口,只不过这个接口被Dao注解注释着而已,接口里面的方法则对应着sql中的增删改查等方法。
  • Room:一个抽象类,被Database注解注释着,里面持有着真正的room对象,底层原理其实是初始化sqlite对象。

多说无益,我们直接上代码演示:

  • 添加gradle依赖
dependencies {
    def room_version = "2.4.1"//目前最新的稳定版本
	implementation "androidx.room:room-runtime:$room_version"
    annotationProcessor "androidx.room:room-compiler:$room_version"
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • Entity层
package com.example.roomdemo01.simple1.entity;

import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.PrimaryKey;

@Entity
public class Student {

    // 主键 SQL 唯一的       autoGenerate自增长
    @PrimaryKey(autoGenerate = true)
    private int uid;

    @ColumnInfo(name = "name")
    private String name;

    @ColumnInfo(name = "pwd")
    private String password;

    @ColumnInfo(name = "address")
    private int address;
    
// 注意:升级的字段  1-2个升级开始
    @ColumnInfo(name = "flag")
    private int flag;

    public int getFlag() {
        return flag;
    }

    public void setFlag(int flag) {
        this.flag = flag;
    }

    public Student(String name, String password, int address) {
        this.name = name;
        this.password = password;
        this.address = address;
    }

    public int getUid() {
        return uid;
    }

    public void setUid(int uid) {
        this.uid = uid;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public int getAddress() {
        return address;
    }

    public void setAddress(int address) {
        this.address = address;
    }

    @Override
    public String toString() {
        return "Student{" +
                "uid=" + uid +
                ", name='" + name + '\'' +
                ", password='" + password + '\'' +
                ", address='" + address + '\'' +
                '}';
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
package com.example.roomdemo01.simple1.entity;


import androidx.room.ColumnInfo;

// @Entity 不能加,加了就是一张表了
public class StudentTuple {

    @ColumnInfo(name = "name")//需要添加ColumnInfo注解,这样才能知道数据库的字段映射到model的哪个属性
    public String name;

    @ColumnInfo(name="pwd")
    public String password;

    public StudentTuple(String name, String password) {
        this.name = name;
        this.password = password;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    @Override
    public String toString() {
        return "StudentTuple{" +
                "name='" + name + '\'' +
                ", password='" + password + '\'' +
                '}';
    }

}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • @Entity标签用于将Student类与Room中的数据表对应起来。tableName属性可以为数据表设置表名,若不设置,则表名与类名相同,也就是说model类加了Entity标签就是一张表

  • @PrimaryKey标签用于指定该字段作为表的主键。

  • @ColumnInfo标签可用于设置该字段存储在数据库表中的名字,并指定字段的类型。

  • @Ignore标签用来告诉Room忽略该字段或方法。Room不会持久化被@Ignore标签标记过的字段的数据

  • Dao层

package com.example.roomdemo01.simple1.dao;

import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Insert;
import androidx.room.Query;
import androidx.room.Update;

import com.example.roomdemo01.simple1.entity.Student;
import com.example.roomdemo01.simple1.entity.StudentTuple;

import java.util.List;

@Dao
public interface StudentDao {

    @Insert
    void insert(Student... students);

    @Delete
    void delete(Student student);

    @Update
    void update(Student student);

    @Query("select * from Student")
    List<Student> getAll();

    // 查询一条记录
    @Query("select * from Student where name like:name")
    Student findByName(String name);

    // 数组查询 多个记录
    @Query("select * from Student where uid in(:userIds)")
    List<Student> getAllId(int[] userIds);

    //我们只查询name pwd字段,如果给Student类接收,那么address字段就会为null;
    //如果不想出现这种情况,我们可以新建一个普通的Model类,切记该类不能添加Entity注解,因为添加了就是一张表了
    @Query("select name,pwd from Student")
    StudentTuple getRecord();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • DataBase层
// 为了养成好习惯 规则 ,要写 exportSchema = false ,因为在升级过程中会记录所有的历史版本信息,,因为内部要记录升级的所有副本
//使用抽象类是为了能够在编译时期使用apt生成真正的类
@Database(entities = {Student.class}, version = 1, exportSchema = false)
public abstract class AppDataBase extends RoomDatabase {

private static final AppDataBase databaseInstance;
private static final String DATABASE_NAME = "my_db"

public static synchronized AppDataBase getInstance(Context context) {
        if (databaseInstance == null) {
            databaseInstance = Room.databaseBuilder(
                    context.getApplicationContext(),
                    AppDataBase.class, DATABASE_NAME)
                    //从assets/database目录下读取students.db
                    //.createFromAsset("databases/students.db")
                    // 可以设置强制主线程,默认是让你用子线程
                    // .allowMainThreadQueries()
                    .build();
        }
        return databaseInstance;
    }

    // 暴露dao
    public abstract StudentDao userDao();

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • @Database标签用来告诉系统这是Room数据库对象。entity属性用于指定该数据库有哪些表,若需要建立多张表,则表名以逗号隔开。version属性用于指定数据库版本号,后面数据库的升级正是根据版本号进行判断的。

  • ui层

package com.example.roomdemo01.simple1.ui;

import android.os.Bundle;
import android.util.Log;

import androidx.appcompat.app.AppCompatActivity;
import androidx.room.Room;

import com.example.roomdemo01.R;
import com.example.roomdemo01.simple1.dao.StudentDao;
import com.example.roomdemo01.simple1.db.AppDataBase;
import com.example.roomdemo01.simple1.entity.Student;
import com.example.roomdemo01.simple1.entity.StudentTuple;

import java.util.List;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // 数据库的操作应该是在子线程
        DbTest t = new DbTest();
        t.start();

    }

    // 测试我们刚刚写的 三个角色Room数据库 增删改查
    public class DbTest extends Thread {
        @Override
        public void run() {
            // 数据库的操作都在这里
            AppDataBase MyDB = AppDataBase.getInstance(MainActivity.this);

            StudentDao dao = MyDB.userDao();

            dao.insert(new Student("Studnet1", "123", 1));
            dao.insert(new Student("Studnet2", "456", 2));
            dao.insert(new Student("Studnet3", "789", 3));
            dao.insert(new Student("Studnet4", "111", 4));

            // 查询全部数据
            List<Student> all = dao.getAll();
            Log.d("DbTest", "run: all.toString():" + all.toString());

            Log.i("DeDbTest", "--------------------------");

            // 查询名字为 Studnet3 的一条数据
            Student stu = dao.findByName("Studnet3");
            Log.d("DbTest", "run: stu.toString():" + stu.toString());

            Log.i("DbTest", "--------------------------");

            // 查询 2 3 4 uid的三条数据
            List<Student> allID = dao.getAllId(new int[]{2, 3, 4});
            Log.d("DbTest", "run: allID.toString():" + allID.toString());

            Log.i("DbTest", "--------------------------");

            // 查询student表里面的数据  到  StudentTuple里面去
            StudentTuple record = dao.getRecord();
            Log.d("DbTest", "run: record.toString():" + record.toString());
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67

是不是很像后端中spring的开发方式,分层以及大量的注解。

运行结果如下图所示:
在这里插入图片描述

高级使用

现在,我们知道了room的基本用法了。这里又个疑问,Google为什么要大费周章的弄个room呢?如果仅仅只是为了对sqlite做一层封装那么个人感觉有点大材小用了。其实,Room组件最大的魅力在于能够于livedata组件一起使用。LiveData组件大家知道:它是整个jetpack的核心组件,只要数据有一点改变那么ui层就能立马得知,从而改变ui。这可就和Google推荐的开发模式:jetpack+kotlin+mvvm遥相呼应了。

下面,来演示下LiveData如何与Room结合使用,其实用法不难:

 // 在dao层中使用 LiveData 关联 Room,这样从数据库中拿到的数据便可以映射到LiveData中
    @Query("select * from Student order by uid")
    LiveData<List<Student>> getAllLiveDataStudent();
  • 1
  • 2
  • 3

然后,我们在ui层中对LiveData进行观察

package com.example.roomdemo02;

import android.os.Bundle;
import android.widget.ListView;

import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProviders;

import java.util.List;

public class MainActivity extends AppCompatActivity {

    ListView listView;
    StudentViewModel studentViewModel;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        listView = findViewById(R.id.listView);

        studentViewModel = ViewModelProviders.of(this).get(StudentViewModel.class);

        // 观察者,只要页面在前台,只要数据改变,用户就可以肉眼看到这种变化
        studentViewModel.getAllLiveDataStudent().observe(this, new Observer<List<Student>>() {
            @Override
            public void onChanged(List<Student> students) {
                // 更新UI
                listView.setAdapter(new GoodsAdapter(MainActivity.this, students));
            }
        });
        // 模拟 仓库
        new Thread()  {
            @Override
            public void run() {
                super.run();

                try {
                    Thread.sleep(6000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                // 默认给数据库ROOM增加数据
                for (int i = 0; i < 50; i++) {
                    studentViewModel.insert(new Student("Student", "123", 1));
                }
            }
        }.start();

        // 模拟仓库 数据库 数据被修改了,一旦数据库被修改,那么数据会驱动UI的发生改变
        new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 50; i++) {
                    try {
                        Thread.sleep(1000);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                    studentViewModel.update(new Student(6, "Student" + i, "123", 1));
                }
            }
        }.start();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68

注意:LiveData通常与ViewModel一起使用,ViewModel是用于存放数据的,因此可以将数据库放在ViewModel中进行实例化。但数据库的实例化需要用到Context,而ViewModel中最好不要传入Context,因此,我们不宜直接使用ViewModel,而应该用它的子类AndroidViewModel,并且在viemodel的构造函数中传入Application类型的上下文。

Room数据库版本升级

import android.content.Context;

import androidx.annotation.NonNull;
import androidx.room.Database;
import androidx.room.Room;
import androidx.room.RoomDatabase;
import androidx.room.migration.Migration;
import androidx.sqlite.db.SupportSQLiteDatabase;

// exportSchema = false 尽量写,内部需要检测,如果没有写,会抛出异常,因为内部要记录升级的所有副本
@Database(entities = {Student.class}, version = 2, exportSchema = false)
public abstract class AppDatabase  extends RoomDatabase {

    private static AppDatabase instance;

    public static synchronized AppDatabase getInstance(Context context) {
        if (instance == null) {
            instance = Room.databaseBuilder(context.getApplicationContext(),
                    AppDatabase.class
                    , "my_db")

                    // 可以强制在主线程运行数据库操作
                    .allowMainThreadQueries()

                    // 暴力升级 不管三七二十一 强制执行(数据会丢失)(慎用)
                    // .fallbackToDestructiveMigration()

                    // 稳定的方式升级
                    .addMigrations(MIGRATION_1_2)

                    .build();
        }
        return instance;
    }

    public abstract StudentDao studentDao();

    // 下面是稳定升级的方式
    static final Migration MIGRATION_1_2 = new Migration(1, 2) {
        @Override
        public void migrate(SupportSQLiteDatabase database) {
            // 在这里用SQL脚本完成数据的变化
            database.execSQL("alter table student add column flag integer not null default 1");
        }
    };

    // ROOM 是不能降级的,我非要删除一个字段,却要保证数据的稳定性,这个是特殊情况
    // 特殊手法降级
    static final Migration MIGRATION_2_3 = new Migration(2, 3) {
        @Override
        public void migrate(@NonNull SupportSQLiteDatabase database) {
            // SQL 四步法

            // 1.先建立临时表
            //  database.execSQL("create table student_temp (uid integer primary key not null,name text,pwd text,addressId)");

            // 2.把之前表的数据(SQL语句的细节,同学们可以上网查询下)
            // database.execSQL("insert into student_temp (uid,name,pwd,addressid) " + " select uid,name,pwd,addressid from student");

            // 3.删除student 旧表
            // database.execSQL("drop table student");

            // 4.修改 临时表 为 新表 student
            // database.execSQL("alter table student_temp rename to student");
        }
    };
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68

源码分析

首先,我们需要明确我们的疑惑是什么?笔者在学习room时的疑惑有下面几点:

  1. room是如何封装sqlite?
  2. Dao层中哪些带注解的方法又是如何被调用的呢?

接下来,我们就带着这两个疑惑来看源码。首先,我们从Room的初始化方法开始看起。我们知道Room采用了建造者模式

public T build() {
            //noinspection ConstantConditions
            if (mContext == null) {
                throw new IllegalArgumentException("Cannot provide null context for the database.");
            }
            //noinspection ConstantConditions
            if (mDatabaseClass == null) {
                throw new IllegalArgumentException("Must provide an abstract class that"
                        + " extends RoomDatabase");
            }
            if (mQueryExecutor == null && mTransactionExecutor == null) {
                mQueryExecutor = mTransactionExecutor = ArchTaskExecutor.getIOThreadExecutor();
            } else if (mQueryExecutor != null && mTransactionExecutor == null) {
                mTransactionExecutor = mQueryExecutor;
            } else if (mQueryExecutor == null && mTransactionExecutor != null) {
                mQueryExecutor = mTransactionExecutor;
            }

            if (mMigrationStartAndEndVersions != null && mMigrationsNotRequiredFrom != null) {
                for (Integer version : mMigrationStartAndEndVersions) {
                    if (mMigrationsNotRequiredFrom.contains(version)) {
                        throw new IllegalArgumentException(
                                "Inconsistency detected. A Migration was supplied to "
                                        + "addMigration(Migration... migrations) that has a start "
                                        + "or end version equal to a start version supplied to "
                                        + "fallbackToDestructiveMigrationFrom(int... "
                                        + "startVersions). Start version: "
                                        + version);
                    }
                }
            }

            if (mFactory == null) {
                mFactory = new FrameworkSQLiteOpenHelperFactory();
            }

            if (mCopyFromAssetPath != null || mCopyFromFile != null) {
                if (mName == null) {
                    throw new IllegalArgumentException("Cannot create from asset or file for an "
                            + "in-memory database.");
                }
                if (mCopyFromAssetPath != null && mCopyFromFile != null) {
                    throw new IllegalArgumentException("Both createFromAsset() and "
                            + "createFromFile() was called on this Builder but the database can "
                            + "only be created using one of the two configurations.");
                }
                mFactory = new SQLiteCopyOpenHelperFactory(mCopyFromAssetPath, mCopyFromFile,
                        mFactory);
            }
//上面这些都是些支线逻辑,我们不需要看
            DatabaseConfiguration configuration =
                    new DatabaseConfiguration(
                            mContext,
                            mName,
                            mFactory,
                            mMigrationContainer,
                            mCallbacks,
                            mAllowMainThreadQueries,
                            mJournalMode.resolve(mContext),
                            mQueryExecutor,
                            mTransactionExecutor,
                            mMultiInstanceInvalidation,
                            mRequireMigration,
                            mAllowDestructiveMigrationOnDowngrade,
                            mMigrationsNotRequiredFrom,
                            mCopyFromAssetPath,
                            mCopyFromFile);
            T db = Room.getGeneratedImplementation(mDatabaseClass, DB_IMPL_SUFFIX);
        
            db.init(configuration);
            return db;
        }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72

上面的build方法中重点在于最后一句init方法,我们进入init方法看下究竟做了什么事情。
在这里插入图片描述
点击去之后,发现居然是个抽象方法,那么我们进入它是实现类看下,这个AppDatabase_Impl其实就是APT生成的类
在这里插入图片描述
看到这里大家应该都明白了吧!其实room底层也是调用了sqlite来创建数据库的。我们看下createAllTables方法是怎么被调的。
在这里插入图片描述
createAllTables被RoomOpenHelper的onCreate方法调用,而RoomOpenHelper的onCreate方法则是被OpenHelper类的onCreate方法调用,这个OpenHelper类则是在FrameworkSQLiteOpenHelper类的构造方法中被初始化,而FrameworkSQLiteOpenHelper则是在前面的build方法的时候被初始化的。
在这里插入图片描述
至此,整个room的初始化逻辑链路全部连接起来了。
至于,第二点则跟简单了,APT会生成一个Impl类,此例是StudentDao_Impl类,我们调用的其实就是该类的对应方法。
在这里插入图片描述

最后

感谢各位读者耐心读到最后,谢谢!!!

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/我家自动化/article/detail/477728
推荐阅读
相关标签
  

闽ICP备14008679号