SQLite数据库的使用
app通常需要保存一些有用的数据在本地,如果数据量小,比如app的一些配置信息,可以考虑使用轻量级的SharedPreferences来保存。如果数据量大,且要进行复杂的操作,则可能需要使用数据库来保存数据,而SQLite是Android系统默认支持的一款数据库。
业务描述:这篇文章里我们试图来完成这样一个业务,app支持多账号切换,且每个账号都会产生一些训练(比如跳绳)数据,当app没有联网时,把训练数据暂时保存到本地,当连上网络时,就上传训练数据然后清空本地训练数据,这样即使没有网络,也不至于造成训练数据的丢失,且可保证app正常使用。这个业务使用SharedPreferences来保存数据显然不太合适,因为每个账号都会产生数据,且数据量可能比较大,而且要把账号资料和训练数据对应起来,使用关系型数据库最合适不过了。
业务分析:根据以上业务,可以知道这个数据库里至少需要两个表,一个是用户表,一个是训练表。
表结构设计:
- 用户表:用户id,用户昵称、性别 等等
- 训练表: 用户id, 训练时间,训练个数 等等
根据以上分析,就可以着手建立数据库了。
1. 基础使用
1.1 SQLiteOpenHelper
SQLiteOpenHelper是一个用来创建数据库,及对数据库进行版本管理的类。
我们首先要做的就是自定义一个类来继承它,然后实现它的构造方法,数据库创建方法,及版本升级方法。如下:
public class MyDataBaseHelper extends SQLiteOpenHelper {
private static final String TAG = "MyDataBaseHelper";
private static final int DB_VERSION = 1;
private static final String DB_NAME = "mydb.db";
public MyDataBaseHelper(Context context) {
super(context, DB_NAME, null, DB_VERSION);
}
@Override
public void onCreate(SQLiteDatabase sqLiteDatabase) {
Log.e(TAG, "onCreate SQLite");
sqLiteDatabase.execSQL(UserTable.SQL_CREATE_TABLE);
}
@Override
public void onUpgrade(SQLiteDatabase sqLiteDatabase, int oldVersion, int newVersion) {
Log.e(TAG, "onUpgrade SQLite -->> oldVersion = " + oldVersion + " ; newVersion = " + newVersion);
}
}
这里我们定义了一个MyDataBaseHelper 类来继承SQLiteOpenHelper类,并在这个类里定义了数据库的名称 DB_NAME ,定义了数据库版本号 DB_VERSION ,实现了构造方法,并重写了数据库的创建方法 onCreate 和版本升级方法 onUpgrade。数据表生成语句就在在 onCreate 方法里执行。这里只生成了一个用户表,训练表等后面版本升级的时候再添加,以模拟版本升级时添加数据表的操作。
1.2 定义数据及数据表
我们把数据表的定义全放在同一个类里,以方便管理。如下:
public final class TablesContract {
private TablesContract(){
//防止被初始化
}
public static class UserTable implements BaseColumns{
//user表名 字段名 及 建表语句
public static final String TABLE_NAME = "table_user";
public static final String COLUMN_ID = "user_id";
public static final String COLUMN_NAME = "user_name";
public static final String COLUMN_GENDER = "user_gender";
public static final String COLUMN_AGE = "user_age";
public static final String SQL_CREATE_TABLE = "create table if not exists " + TABLE_NAME + " ( "
+ COLUMN_ID + " text not null,"
+ COLUMN_NAME + " text not null,"
+ COLUMN_GENDER + " integer,"
+ COLUMN_AGE + " integer)";
}
}
自定义了一个 TablesContract 类,在里面定义了 用户资料表的 表名、字段名及SQL建表语句,目前用户表的字段有:用户id、用户名、性别、年龄 。如果数据库还需要添加其它表,也可以同样的方式定义在这个类里。
同时,对应用户表的字段定义一个用户对象类,如下:
public class UserBean {
private String id;
private String name;
private int gender;
private int age;
public UserBean(String id, String name, int gender, int age) {
this.id = id;
this.name = name;
this.gender = gender;
this.age = age;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
... ...
@Override
public String toString() {
return "UserBean{" +
"id='" + id + '\'' +
", name='" + name + '\'' +
", gender=" + gender +
", age=" + age +
'}';
}
}
示例代码省略了一些get、set方法,就不全部放上来了。
1.3 定义一个数据库操作类
定义这个类,主要是为了把数据库与上层应用分离开,使用的时候不必考虑数据库操作详情,只需要调用相应的方法就可以存取、删改数据。
如下:
public class MyDatabaseOperator {
private static SQLiteOpenHelper openHelper;
public MyDatabaseOperator(Context context){
openHelper = new MyDataBaseHelper(context);
}
//保存用户资料
public boolean saveUser(UserBean userBean){
SQLiteDatabase database = openHelper.getWritableDatabase();
ContentValues values = new ContentValues();
values.put(UserTable.COLUMN_ID, userBean.getId());
values.put(UserTable.COLUMN_NAME, userBean.getName());
values.put(UserTable.COLUMN_GENDER, userBean.getGender());
values.put(UserTable.COLUMN_AGE, userBean.getAge());
long result = database.insert(UserTable.TABLE_NAME, null, values);
database.close();
if(result == -1){
return false;
}else {
return true;
}
}
//获取全部用户资料
public List<UserBean> getUserList(){
SQLiteDatabase database = openHelper.getReadableDatabase();
final String querySQL = "select * from " + UserTable.TABLE_NAME;
Cursor cursor = database.rawQuery(querySQL, null);
List<UserBean> userList = new ArrayList<>();
if(cursor != null && cursor.getCount() > 0){
while (cursor.moveToNext()){
UserBean userBean = new UserBean(
cursor.getString(cursor.getColumnIndex(UserTable.COLUMN_ID)),
cursor.getString(cursor.getColumnIndex(UserTable.COLUMN_NAME)),
cursor.getInt(cursor.getColumnIndex(UserTable.COLUMN_GENDER)),
cursor.getInt(cursor.getColumnIndex(UserTable.COLUMN_AGE))
);
userList.add(userBean);
}
}
cursor.close();
database.close();
return userList;
}
}
在这个类里定义了两个方法,一个用来保存用户资料,一个用来取出所有用户资料。这里需要说明一个问题,在这两个方法的结尾,都有关闭数据库的操作。但是如果有多个线程同时操作数据库,当一个线程关闭了数据库,另一个线程还在操作时,就会导致bug。因此如果有多个线程频繁使用数据库,是不建议这样直接关闭的。但是我们这里只需要简单的操作,这样写并没有问题,至于多线程操作数据库的情况,我们后面再讨论。
1.4 在Activity里使用数据库
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
tvDatabase = findViewById(R.id.tv_database);
Button btnScan = findViewById(R.id.btn_scan_database);
btnScan.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
scanDatabase();
}
});
Button btnSave = findViewById(R.id.btn_save_database);
btnSave.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
saveData();
}
});
}
private void scanDatabase(){
new ScanDataTask().execute();
}
private void saveData(){
new Thread(new Runnable() {
@Override
public void run() {
MyDatabaseOperator operator = new MyDatabaseOperator(getApplicationContext());
boolean result = operator.saveUser(new UserBean("123", "chenrenxiang", 1, 26));
if(result){
runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(MainActivity.this, "data saved successful", Toast.LENGTH_SHORT).show();
}
});
}
}
}).start();
}
private class ScanDataTask extends AsyncTask<Void, Void, List<UserBean>>{
@Override
protected List<UserBean> doInBackground(Void... voids) {
MyDatabaseOperator operator = new MyDatabaseOperator(getApplicationContext());
return operator.getUserList();
}
@Override
protected void onPostExecute(List<UserBean> userBeans) {
String result = "";
for(UserBean userBean : userBeans){
result += userBean.toString() + "\n";
}
tvDatabase.setText(result);
}
}
在Activity里模拟了插入一条用户资料和取出用户资料的操作。数据库操作是耗时操作,最好写在子线程里。
插入之后,使用Stetho查看新建的数据库、数据表,及添加的用户资料。可以看到,数据已经成功插入了数据表。
2. 数据库升级
在上面的操作中,我们建立了一个数据库,并创建了一个 用户表,定义了一些基本的操作。现在,随着app的升级,我们需要更新数据库。比如,我们需要给用户表添加一个字段,以存储用户的头像url地址,然后要新加一个 训练表 ,以存储用户的训练数据。
2.1 修改已有数据表
首先,我们给用户对象类 UserBean 添加一个 avatar 字段,对 UserBean 类作一些相应的修改。
private String id;
private String name;
private int gender;
private int age;
private String avatar; //新加字段
public UserBean(String id, String name, int gender, int age, String avatar) {
this.id = id;
this.name = name;
this.gender = gender;
this.age = age;
this.avatar = avatar;
}
public String getAvatar() {
return avatar;
}
public void setAvatar(String avatar) {
this.avatar = avatar;
}
然后在 TablesContract 类中把 avatar 这个字段添加给用户表,建表语句也不要忘了修改
public static class UserTable implements BaseColumns{
//user表名 字段名 及 建表语句
public static final String TABLE_NAME = "table_user";
public static final String COLUMN_ID = "user_id";
public static final String COLUMN_NAME = "user_name";
public static final String COLUMN_GENDER = "user_gender";
public static final String COLUMN_AGE = "user_age";
public static final String COLUMN_AVATAR = "user_avatar"; //新加字段
public static final String SQL_CREATE_TABLE = "create table if not exists " + TABLE_NAME + " ( "
+ COLUMN_ID + " text not null,"
+ COLUMN_NAME + " text not null,"
+ COLUMN_GENDER + " integer,"
+ COLUMN_AVATAR + " text,"
+ COLUMN_AGE + " integer)";
}
2.2 新增数据表
首先新建一个训练对象类 TrainBean,如下:
public class TrainBean {
private String id; //用户id
private long time; //训练时间,用时间戳记录
private int duration; //训练时长,以秒为单位记录
private int jumps; //跳绳个数
public TrainBean(String id, long time, int duration, int jumps) {
this.id = id;
this.time = time;
this.duration = duration;
this.jumps = jumps;
}
... ... 省略了get、set方法和toString方法
}
然后在 TablesContract 类中定义训练表 TrainTable, 如下:
public static class TrainTable implements BaseColumns{
//表名
public static final String TABLE_NAME = "table_train";
//字段名
public static final String COLUMN_ID = "user_id";
public static final String COLUMN_TIME = "train_time";
public static final String COLUMN_DURATION = "train_duration";
public static final String COLUMN_JUMPS = "train_jumps";
//建表语句
public static final String SQL_CREATE_TRAIN_TABLE = "create table if not exists " + TABLE_NAME + " ( "
+ COLUMN_ID + " text not null,"
+ COLUMN_TIME + " long not null,"
+ COLUMN_DURATION + " integer,"
+ COLUMN_JUMPS + " integer)";
}
2.3 数据库升级管理
数据库升级有两种情况:
1. 用户没有装过以前版本的app,或者卸载重装新版本的app
2. 用户手机里已经有以前版本的app,现在覆盖安装新版本app
对于情况1,需要新建数据库,程序会进入到 SQLiteOpenHelper 类里面的 onCreate 方法,不会进入 onUpgrade 方法。 而情况2,需要升级数据库,程序会进入 onUpgrade 方法,不会进入onCreate 方法。
分析完这两种情况,就可以开始对 MyDataBaseHelper 类行进修改了:
private static final int DB_VERSION = 1;
private static final int DB_VERSION_2 = 2;
private static final String DB_NAME = "mydb.db";
public MyDataBaseHelper(Context context) {
super(context, DB_NAME, null, DB_VERSION_2);
}
@Override
public void onCreate(SQLiteDatabase sqLiteDatabase) {
Log.e(TAG, "onCreate SQLite");
sqLiteDatabase.execSQL(UserTable.SQL_CREATE_USER_TABLE);
//新增训练表
sqLiteDatabase.execSQL(TrainTable.SQL_CREATE_TRAIN_TABLE);
}
@Override
public void onUpgrade(SQLiteDatabase sqLiteDatabase, int oldVersion, int newVersion) {
Log.e(TAG, "onUpgrade SQLite -->> oldVersion = " + oldVersion + " ; newVersion = " + newVersion);
//如果是从版本1升级到版本2,先向用户表添加avatar字段,然后新建训练表
if(oldVersion == DB_VERSION && newVersion == DB_VERSION_2){
//向用户表插入avatar字段的SQL语句
String upgradeUserSQL = "alter table " + UserTable.TABLE_NAME + " add " + UserTable.COLUMN_AVATAR + " text";
sqLiteDatabase.execSQL(upgradeUserSQL);
sqLiteDatabase.execSQL(TrainTable.SQL_CREATE_TRAIN_TABLE);
}
}
- 数据库升级的时候,新的版本号必须比之前的版本号大,这里我没有直接把 DB_VERSION 改大,而是新建了一个 DB_VERSION_2 来保存新的版本号,主要是为了保存以前的版本号。因为随着数据库的升级,会出现各种覆盖安装的情况。比如说可能重版本1升到版本4,也可能从版本2升到版本4,而不同的升级情况数据库变动的内容通常也是不同的,需要进行判断然后分别处理。
- 以上是考虑到需要保存以前版本的本地数据,如果以前版本的本地数据不重要,那么可以直接在 onUpgrade 方法里删除以前的数据表,再调用 onCreate 方法新建数据表就行。如下:
public void onUpgrade(SQLiteDatabase sqLiteDatabase, int oldVersion, int newVersion) {
Log.e(TAG, "onUpgrade SQLite -->> oldVersion = " + oldVersion + " ; newVersion = " + newVersion);
String deleteTableSQL = "drop table " + UserTable.TABLE_NAME;
sqLiteDatabase.execSQL(deleteTableSQL);
onCreate(sqLiteDatabase);
}
2.4 测试升级
在数据库操作类 MyDatabaseOperator 中添加一个向训练表 TrainTable 插入一条训练数据的方法,在Activity中调用此方法向训练表插入一条数据,使用Stetho查看数据是否插入成功。
//插入训练数据
public boolean saveTrainData(TrainBean trainBean){
SQLiteDatabase database = openHelper.getWritableDatabase();
ContentValues values = new ContentValues();
values.put(TrainTable.COLUMN_ID, trainBean.getId());
values.put(TrainTable.COLUMN_TIME, trainBean.getTime());
values.put(TrainTable.COLUMN_DURATION, trainBean.getDuration());
values.put(TrainTable.COLUMN_JUMPS, trainBean.getJumps());
long result = database.insert(TrainTable.TABLE_NAME, null, values);
database.close();
if(result == -1){
return false;
}else {
return true;
}
}
1. 测试覆盖升级: 可以看到程序只进入了onUpgrage方法,如下:
插入完成后查看数据库,可以看到新建的训练表已经存在,且用户表新增了avatar字段:
2. 测试重新安装: 可以看到程序只进入了 onCreate方法,如下:
3. 数据库的多线程操作
前面提到过当多个线程同时操作数据库时可能会导致bug,下面我们来模拟一下多线程操作数据库,当一个线程工作完成之后关闭了数据库,另一个线程仍然在运行的情况。
首先我们把 保存训练数据 的方法稍微改动一下,让它接受一个count参数,然后重复插入count条数据。
//插入训练数据
public boolean saveTrainData(TrainBean trainBean, int count){
SQLiteDatabase database = openHelper.getWritableDatabase();
ContentValues values = new ContentValues();
long result = 0;
for(int i=0; i<count; i++){
values.put(TrainTable.COLUMN_ID, trainBean.getId());
values.put(TrainTable.COLUMN_TIME, trainBean.getTime());
values.put(TrainTable.COLUMN_DURATION, trainBean.getDuration());
values.put(TrainTable.COLUMN_JUMPS, trainBean.getJumps());
result = database.insert(TrainTable.TABLE_NAME, null, values);
}
database.close();
if(result == -1){
return false;
}else {
return true;
}
}
然后把Activity里保存训练数据的方法也稍微改一下。当点击按钮时,同时开两个线程保存数据,一个线程保存2条,一个线程保存10条,同时把保存的结果打印出来。
... ...
btnSave.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
saveData(2);
saveData(10);
}
});
}
private void saveData(final int count){
new Thread(new Runnable() {
@Override
public void run() {
MyDatabaseOperator operator = new MyDatabaseOperator(getApplicationContext());
boolean result = operator.saveTrainData(new TrainBean("123", 123454, 60, 144), count);
// boolean result = operator.saveUser(new UserBean("123", "chenrenxiang", 1, 26));
if(result){
Log.e(TAG, "save" + count + "data successful");
}
}
}).start();
}
运行程序,点击按钮后立即奔溃,log如下:
com.xiaoqiang.sqlitelearn E/MainActivity: save 2 data successful
com.xiaoqiang.sqlitelearn E/AndroidRuntime: FATAL EXCEPTION: Thread-4213
Process: com.xiaoqiang.sqlitelearn, PID: 18405
java.lang.IllegalStateException: attempt to re-open an already-closed object: SQLiteDatabase: /data/data/com.xiaoqiang.sqlitelearn/databases/mydb.db
at android.database.sqlite.SQLiteClosable.acquireReference(SQLiteClosable.java:55)
at android.database.sqlite.SQLiteDatabase.insertWithOnConflict(SQLiteDatabase.java:1439)
at android.database.sqlite.SQLiteDatabase.insert(SQLiteDatabase.java:1341)
at com.xiaoqiang.sqlitelearn.sqlite.MyDatabaseOperator.saveTrainData(MyDatabaseOperator.java:76)
at com.xiaoqiang.sqlitelearn.MainActivity$3.run(MainActivity.java:52)
at java.lang.Thread.run(Thread.java:818)
可以看到,保存2条数据的线程运行完成,并打印出了 “save 2 data successful”,而保存10条数据的线程出错,原因是 “attempt to re-open an already-closed object: SQLiteDatabase: /data/data/com.xiaoqiang.sqlitelearn/databases/mydb.db” ,即试图重新打开已经关闭的数据库。
3.1 多线程操作的可行方案
那么如何解决这个bug呢,目前我看到的最佳解决方案是这篇博客:Concurrent database access
写得很清楚,很详细,我就不复述了。
接下来我要做的就是参考这篇博客,修复当前的bug。
- 首先新建 DatabaseManager 类,完全复制它的代码。
public class DatabaseManager {
private AtomicInteger mOpenCounter = new AtomicInteger();
private static DatabaseManager instance;
private static SQLiteOpenHelper mDatabaseHelper;
private SQLiteDatabase mDatabase;
public static synchronized void initializeInstance(SQLiteOpenHelper helper) {
if (instance == null) {
instance = new DatabaseManager();
mDatabaseHelper = helper;
}
}
public static synchronized DatabaseManager getInstance() {
if (instance == null) {
throw new IllegalStateException(DatabaseManager.class.getSimpleName() +
" is not initialized, call initializeInstance(..) method first.");
}
return instance;
}
public synchronized SQLiteDatabase openDatabase() {
if(mOpenCounter.incrementAndGet() == 1) {
// Opening new database
mDatabase = mDatabaseHelper.getWritableDatabase();
}
return mDatabase;
}
public synchronized void closeDatabase() {
if(mOpenCounter.decrementAndGet() == 0) {
// Closing database
mDatabase.close();
}
}
}
- 在Application中初始化 DatabaseManager ,并传入自定义的 DatbaseOpenHelper。
private void initDatabase(){
DatabaseManager.initializeInstance(new MyDataBaseHelper(this));
}
- 修改数据库操作类 MyDatabaseOperator
public class MyDatabaseOperator {
private static DatabaseManager manager;
public MyDatabaseOperator(){
manager = DatabaseManager.getInstance();
}
... ...
//插入训练数据
public boolean saveTrainData(TrainBean trainBean, int count){
SQLiteDatabase database = manager.openDatabase();
ContentValues values = new ContentValues();
long result = 0;
for(int i=0; i<count; i++){
values.put(TrainTable.COLUMN_ID, trainBean.getId());
values.put(TrainTable.COLUMN_TIME, trainBean.getTime());
values.put(TrainTable.COLUMN_DURATION, trainBean.getDuration());
values.put(TrainTable.COLUMN_JUMPS, trainBean.getJumps());
result = database.insert(TrainTable.TABLE_NAME, null, values);
}
manager.closeDatabase();
if(result == -1){
return false;
}else {
return true;
}
}
}
获取数据库使用 DatabaseManager.openDatabase() 方法,关闭数据库用DatabaseManager.closeDatabase() 方法。
修改完成之后运行程序,结果如下:
com.xiaoqiang.sqlitelearn E/MainActivity: save 2 data successful
com.xiaoqiang.sqlitelearn E/MainActivity: save 10 data successful
两次操作都运行成功!
放一下demo地址
难免bug,欢迎讨论指正。以上。