严谨一点说,SQLite在Android设备中可以被当做是一种数据存储方法或者干脆就是一个数据库
正如其他大多数平台一样,Android 也提供了几种方法用来保存数据,使得这些数据即使在程序结束以后依然不会丢失。这些方法有:文本文件-可以保存在应用程序自己的目录下(【译者注】安装的每个app都会在/data/data/目录下创建个文件夹,名字和应用程序中AndroidManifest.xml文件中的package一样),也可以保存在SDcard中;Preferences也是一种经常使用的数据存储方法,因为它们对于用户而言是透明的,并且从应用安装的时候就存在了;另外,如果放宽点说的话,Assets也可以用来存储一些只读数据。Assets是指那些在assets目录下的文件,这些文件在你将你的应用编译打包之前就要存在,并且可以在应用程序运行的时候被访问到。以后我会更加详细的聊聊这些方法的细节。
然而,有时候我们需要对保存的数据进行一些复杂的操作,或者数据量很大,超出了文本文件和Preference的性能能hold住的范围,所以需要一些更加高效的方法来管理。这时就需要一个移动平台上的数据库闪亮登场了。
从Android1.5(代号Cupcake)开始,Android就自带SQLite(版本3.5.9+)了。如果你对SQLite不熟悉的话,就把它当成是一个独立的,无需服务进程,支持事务处理,可以使用SQL语言的数据库。尽管SQLite也有它的不足之处,但是在Android开发者的武器库里,可以算是个杀手锏了。
本文中,我主要介绍在Android中使用SQLite的方法,着重介绍它的管理操作,具体而言,就是创建和更新(update)(【译者注】这里说的更新操作不是说使用update语句更新数据库数据的操作,而是修改数据库结构的操作,本文中的update和upgrade都是这个意思,为避免混淆,后注原英文使用动词),而不是那些运行时的操作。
我们可以从创建一个继承自SQLiteOpenHelper的类来管理SQLite开始探讨这一话题,这个类有一个构造方法和另外两个必须实现的方法,onCreate和onUpgrade方法.
很自然的,这些方法中第一个被执行的就是构造方法,在构造函数中调用父类的构造方法,同时传入四个参数:
Context, 这表示应用程序的上下文,在构造函数中保存住,对以后的其他操作有用。
数据库名称,就是个文件名,表示数据库物理文件名称的字符串。
游标factory,如果提供的话,可以用来创建游标。
数据库版本,这是你的数据库的版本(用一个整数表示),稍后我会讨论这个参数的细节。初始值为1。
在我们的例子中,我们的四个参数如下面代码所示:
class DB extends SQLiteOpenHelper { final static int DB_VERSION = 1; final static String DB_NAME = "mydb.s3db"; Context context; public DB(Context context) { super(context, DB_NAME, null, DB_VERSION); // Store the context for later use this.context = context; }
构造函数做两件事情,首先,检查数据库是否存在,如果不存在,则调用onCreate方法创建数据库。然后,如果数据库已经存在了,那么就检查数据库版本是否和构造函数中传入的数据库版本值一致,从而决定数据库是不是已经更新(updated)过了,如果需要更新,则调用onUpgrade方法。
另外,如上所述,我们已经知道onCreate方法只有当数据库不存在的时候才会被调用,因此如果你想在程序安装以后第一次运行时做什么操作的话,这个方法倒不失为一个很方便的手段,你可以在这个方法中调用任何其他方法,比如说许可协议说明对话框。
让我们回头看看数据库本身,因为本文只是一个说明性质的文章,因此这里我只是创建一个简单的雇员信息数据库,创建数据库的SQL脚本如下所示:
CREATE TABLE employees ( _id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, ext TEXT NOT NULL, mob TEXT NOT NULL, age INTEGER NOT NULL DEFAULT '0' );
我们可以很容易的用hard coding的方式将创建脚本写死在代码中,一行对应一行,代码如下:
@Override public void onCreate(SQLiteDatabase database) { database.execSQL(? "CREATE TABLE employees ( _id INTEGER PRIMARY KEY " + "AUTOINCREMENT, name TEXT NOT NULL, ext TEXT NOT NULL, " + "mob TEXT NOT NULL, age INTEGER NOT NULL DEFAULT '0')"); }
但正如你所想的那样,当数据库大小达到了某个值,或者复杂性达到了某个程度时,这种做法就会非常不灵活,因此理想的做法是将SQL脚本放到一个asset文件中。如果这样做的话,你需要写一个方法从assets目录中读取SQL脚本,然后执行它:
@Override public void onCreate(SQLiteDatabase database) { executeSQLScript(database, "create.sql"); } private void executeSQLScript(SQLiteDatabase database, string dbname){ ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); byte buf[] = new byte[1024]; int len; AssetManager assetManager = context.getAssets(); InputStream inputStream = null; try{ inputStream = assetManager.open(dbname); while ((len = inputStream.read(buf)) != -1) { outputStream.write(buf, 0, len); } outputStream.close(); inputStream.close(); String[] createScript = outputStream.toString().split(";"); for (int i = 0; i < createScript.length; i++) { String sqlStatement = createScript[i].trim(); // TODO You may want to parse out comments here if (sqlStatement.length() > 0) { database.execSQL(sqlStatement + ";"); } } } catch (IOException e){ // TODO Handle Script Failed to Load } catch (SQLException e) { // TODO Handle Script Failed to Execute } }
如果是为了创建一个简单数据库的话,这种方法比简单的逐行执行SQL语句来的复杂的多,但是一旦数据库结果变得更复杂或者你想预先写好创建脚本时,这种方法将会使你获益良多。同时你也看到我将执行SQL语句的代码抽象到了一个独立executeSQLScript方法中,这样它可以在其他情况下复用,这一点我在本文后面的代码会证实到的。
(【译者注】这里原作者没有说创建了DB类以后该如何使用DB类来创建数据库,但既然这是个入门级的说明文,应该假设读者没有使用过SQLite,在下一小节的代码中有这样的代码:
DB db = new DB(this); SQLiteDatabase qdb = db.getReadableDatabase();
也可以使用getWritableDatabase(); 得到可以写入的数据库,这里Android会自己根据需要创建数据库,如果数据库文件不存在的话就创建,如果数据库文件已经存在的话,那么Android自己会根据数据库文件中的version信息和DB类构造函数中传入的Version信息对比,如果值不一样的话,会自己调用onUpgrade()方法更新数据库。因此onCreate方法和onUpgrade方法都不是由用户手动调用的。
这两句话可以加在Activity的onCreate方法中,或者onResume方法也是个不错的选择,执行完这句话后,在Android系统的/data/data/package_name/databases目录下就可以看到你创建的数据库文件,文件名就是上面代码中的DB_NAME的值,用DDMS拿出来以后可以用图形化工具打开看看,图形化工具可以使用有免费开源的sqliteman,在https://sqliteman.com/page/4.html下载;另外还有firefox的插件:sqlite manager可以使用)
现在数据库已经创建好了,下面我想和它进行交互。一个简单的操作步骤:
第一步是打开数据库,有两种方法可以做到这点:使用getReadableDatabase()方法或者getWritableDatabase()方法。前者速度快,占用资源少,可以用来做除了写数据库和修改数据库之外的所有操作;后者主要用来做insert, update等操作。
在Android系统中,查询结果集作为一个Cursor对象返回,可以调用query()或者rawQuery()方法执行一次查询,例如,下面的两个方法返回的结果完全相同:
DB db = new DB(this); SQLiteDatabase qdb = db.getReadableDatabase(); Cursor recordset1 = ?qdb.query("mytable", null, null, null, null, null, null); Cursor recordset2 = qdb.rawQuery("SELECT * FROM mytable", null);
第一个查询调用使用了一堆参数,他们分别是数据表名称,一个列名数组, WHERE子句,选择参数数组,GROUP BY子句,HAVING子句以及ORDER BY子句。可以注意到,把这么多参数都设置为null,其作用和你用通配符代替这些参数的效果是一样的,如果你不需要给这些参数赋值,你干脆就不要用这种包含那么多参数的方法。
这里大多数参数对于熟悉SQL语句的人来说相当的直白。不过这个选择参数数组需要一点点说明,它是一个字符串数组,在查询方法中,WHERE子句中可以包含‘?’,然后在查询时,所有问号依次被选择参数数组中的值替换,比如选择参数数组中的第一个值替换掉WHERE子句中第一个‘?’。
再看看rawQuery()方法,它只需要两个参数,第一个是SQL查询语句,第二个是选择参数数组-它的作用和query方法中的一样。选择参数数组一般和复杂的查询一起使用,比如说使用到JOIN操作的时候。(【译者注】这里原作者说到做连接操作,我们知道sqlite仅支持左连接,而且当left join时,连接条件不在where子句中,因此这里应该指select x1, x2 from tables1, tables 2 where table1.?=tables2.? 这样的连接操作。实际上,我觉得这样的设计应该是为了不需要每次查询都要拼接查询语句字符串,比如说写个select * from tablename where name = ?, 这个?每次查询都不一样,这样可以通过选择参数数组中的值来替换?,而不需要每次都”select * from tablename where name = ‘“+Michael+”’” 这样拼接字符串,Java中拼接字符串的代价是比较高的,特别是查询条件比较多的时候,连续的几次字符串+操作会创建一堆String对象)。
再回到数据库管理上来,让我们看一下有点小复杂的情况,数据库更新。经过一段时间的使用和开发,应用程序往往会发生变化,也许会添加新的功能,也许做了某些优化。这些变化也许需要数据库结构发生变化,并且在数据库更新代码中,通过数据库版本这个值来反映出这次更新。
在更新数据库时有个潜在问题,那就是有可能导致先前版本的数据库数据丢失。另外,一旦我们的应用版本超过了两个,我们不能武断的假设用户总是已经更新到最新的版本了,比如说现在发布的版本是version3.0,那么不能假设所有用户都已经更新到version2.0了,所以我们的更新操作不能是简单的从一个版本更新到下一本版本。
那么怎么处理这种问题呢?我们已经知道当有一个新版本数据库的时候,onUpgrade()方法就会被调用,所以理想的做法是我们在这个方法中判断数据库版本,决定执行一段或者多段更新脚本。
让我们看一下我们的例子中,在2.0版本中要做哪些修改:
电话号码格式标准化(分机号,手机号),将电话号码存到一张独立的“numbers”数据表。
在雇员数据表中增加一个薪水字段。
以版本1数据库为更新点,可以通过下面的SQL脚本实现更新数据库操作,并且将原来版本中的数据导入到新版本数据库中。
CREATE TABLE numbers ( _id INTEGER PRIMARY KEY AUTOINCREMENT, employid INTEGER NOT NULL, number TEXT NOT NULL, ntype INTEGER NOT NULL DEFAULT '0' ); CREATE INDEX employid ON numbers(employid); INSERT INTO numbers (employid, number, ntype) SELECT _id, ext, 0? FROM employees; INSERT INTO numbers (employid, number, ntype) SELECT _id, mob, 1? FROM employees; CREATE TABLE temp ( _id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, salary INTEGER NOT NULL DEFAULT '0' ); INSERT INTO temp (_id, name) SELECT _id, name FROM employees; DROP TABLE employees; ALTER TABLE temp RENAME TO employees;
显然,对数据库结构的修改越复杂,SQL脚本写的就越复杂。同时在对SQL的支持上,SQLite与其他数据库相比有更多限制,因此有些时候你需要做些workaround绕过这些限制,举个例子,在上面的更新(update)脚本中,我不得不创建一张临时表作为SQLite不支持DROP COLUMN语句的workaround。
现在已经有更新(upgrade)数据库的SQL脚本了,下一步是就如何在onUpgrade方法中调用SQL脚本,下面给一个实现方法:
@Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (newVersion > oldVersion) { switch (oldVersion) { case 1: executeSQLScript(database, "update_v2.sql"); case 2: executeSQLScript(database, "update_v3.sql"); } } }
在这段代码中有两个地方值得注意。:第一件是我在代码里判断新数据库的版本号是不是比旧数据库版本号大,因为onUpdategrade()方法只要两个版本不一致时都会被调用,所以也有可能导致回滚到旧版本的情况。我们的代码不希望看到这种情况,但是实际上应该考虑这种情况并且加上相应的处理代码。
第二件是在case分支语句中没有break。这是因为每个分支中的SQL语句都是简单的从一个版本更新(updates)到下一个版本,这样的话,如果要从版本1更新(upgrade)到版本3的时候,首先会执行从版本1更新(upgrade)到版本2的脚本,然后再执行从版本2更新(upgrade)到版本3的脚本。如果数据库已经是版本2了,那么只会执行版本2到版本3的更新(upgrade)脚本。
这样的话,每次你更新(upgrade)数据库时,只需要考虑从最近的版本更新到新版本需要对数据库结构做哪些修改,写出相应脚本,就可以处理从任何一个版本升级的情况了。当然,在Java代码中我们还需要更新DB_VERION的值还有其他受数据库结构变化影响的代码。
结论
在Android平台的数据持久化方法中,SQLite有着很好数据存储和管理能力,是个不错的选择。然而,和使用其他任何一种数据库一样,需要小心管理,特别是当数据库结构发生变化时更需要小心再小心。
最后,将部分应用逻辑写到SQL脚本中(【译者注】比如在本文例子中从旧数据库中读取数据存到新数据表中这些操作都是用SQL脚本完成的)并且保存到文件中是个简单高效的方法。这种方法让开发人员不需要在程序中写非常复杂的代码处理每次更新(upgrade),可以将主要精力放在应用的业务逻辑上。
英文原文:Gilles Debunne,编译:ImportNew - 赵荣
译文地址:https://www.importnew.com/1324.html
【如需转载,请在正文中标注并保留原文链接、译文链接和译者等信息,谢谢合作!】