从数据库中保存或查询数据
前面一个章节中我们讲了关于SQLiteOpenHelper
的创建,但是我们需要在必要的时候有方法去保存我们的数据到数据库,或者从我们的数据库中查询数据。另外一个叫ForecastDb
类就会做这件事。
创建数据库model类
但是首先,我们要去为数据库创建model类。你还记得我们之前所见的map委托的方式?我们要把这些属性直接映射到数据库中,反过来也一样。
我们先来看下CityForecast
类:
class CityForecast(val map: MutableMap<String, Any?>,
val dailyForecast: List<DayForecast>) {
var _id: Long by map
var city: String by map
var country: String by map
constructor(id: Long, city: String, country: String,
dailyForecast: List<DayForecast>)
: this(HashMap(), dailyForecast) {
this._id = id
this.city = city
this.country = country
}
}
默认的构造函数会得到一个含有属性和对应的值的map,和一个dailyForecast。多亏了委托,这些值会根据key的名字会映射到相应的属性中去。如果我们希望映射的过程运行完美,那么属性的名字必须要和数据库中对应的名字一模一样。我们后面会讲原因。
但是,第二个构造函数也是必要的。这是因为我们需要从domain映射到数据库类中,所以不能使用map,从属性中设置值也是方便的。我们传入一个空的map,但是又一次,多亏了委托,当我们设置值到属性的时候,它会自动增加所有的值到map中。用这种方式,我们就准备好map来保存到数据库中了。使用了这些有用的代码,我将会看见它运行起来就像魔法一样神奇。
现在我们需要第二个类,DayForecast,它会是第二个表。它包括表中的每一列作为它的属性,它也有第二个构造函数。唯一不同之处就是不需要设置id,因为它将通过SQLite自增长。
class DayForecast(var map: MutableMap<String, Any?>) {
var _id: Long by map
var date: Long by map
var description: String by map
var high: Int by map
var low: Int by map
var iconUrl: String by map
var cityId: Long by map
constructor(date: Long, description: String, high: Int, low: Int,
iconUrl: String, cityId: Long)
: this(HashMap()) {
this.date = date
this.description = description
this.high = high
this.low = low
this.iconUrl = iconUrl
this.cityId = cityId
}
}
这些类将会帮助我们SQLite表与对象之间的互相映射。
写入和查询数据库
SqliteOpenHelper
只是一个工具,是SQL世界和OOP之间的一个通道。我们要新建几个类来请求已经保存在数据库中的数据,和保存新的数据。被定义的类会使用ForecastDbHelper
和DataMapper
来转换数据库中的数据到domain models
。我仍旧使用默认值的方式来实现简单的依赖注入:
class ForecastDb(
val forecastDbHelper: ForecastDbHelper = ForecastDbHelper.instance,
val dataMapper: DbDataMapper = DbDataMapper()) {
...
}
所有的函数使用前面章节讲到过的use()
函数。lambda返回的值也会被作为这个函数的返回值。所以让我们定义一个使用zip code
和date
来查询一个forecast
的函数:
fun requestForecastByZipCode(zipCode: Long, date: Long) = forecastDbHelper.use {
...
}
这么没有什么解释的:我们使用use
函数返回的结果作为这个函数返回的结果。
查询一个forecast
第一个要做的查询就是每日的天气预报,因为我们需要这个列表来创建一个city
对象。Anko提供了一个简单的请求构建器,所以我们来利用下这个有利条件:
val dailyRequest = "${DayForecastTable.CITY_ID} = ? " +
"AND ${DayForecastTable.DATE} >= ?"
val dailyForecast = select(DayForecastTable.NAME)
.whereSimple(dailyRequest, zipCode.toString(), date.toString())
.parseList { DayForecast(HashMap(it)) }
第一行,dailyRequest
是查询语句中where
的一部分。它是whereSimple
函数需要的第一个参数,这与我们用一般的helper做的方式很相似。这里有另外一个简化的where
函数,它需要一些tags和values来进行匹配。我不太喜欢这个方式,因为我觉得这个增加了代码的模版化,虽然这个对我们把values解析成String很有利。最后它看起来会是这样:
val dailyRequest = "${DayForecastTable.CITY_ID} = {id}" + "AND ${DayForecastTable.DATE} >= {date}"
val dailyForecast = select(DayForecastTable.NAME)
.where(dailyRequest, "id" to zipCode, "date" to date)
.parseList { DayForecast(HashMap(it)) }
你可以选择你喜欢的一种方式。select
函数是很简单的,它仅仅是需要一个被查询表的名字。parse
函数的时候会有一些魔法在里面。在这个例子中我们假设请求结果是一个list,使用了parseList
函数。它使用了RowParser
或RapRowParser
函数去把cursor转换成一个对象的集合。这两个不同之处就是RowParser
是依赖列的顺序的,而MapRowParser
是从map中拿到作为column的key名的。
在它们之间有两个重载的冲突,所以我们不能直接使用简化的方式准确地创建需要的对象。但是没有什么是不能通过扩展函数来解决的。我创建了一个接收一个lambda函数返回一个MapRowParser
的函数。解析器会调用这个lambda来创建这个对象:
fun <T : Any> SelectQueryBuilder.parseList(
parser: (Map<String, Any>) -> T): List<T> =
parseList(object : MapRowParser<T> {
override fun parseRow(columns: Map<String, Any>): T = parser(columns)
})
这个函数可以帮助我们简单地去parseList
查询的结果:
parseList { DayForecast(HashMap(it)) }
解析器接收的immutable map
被我们转化成了一个mutable map
(我们需要在database model
中是可以修改的)通过使用相应的HashMap
构造函数。在DayForecast
中的构造函数中会使用到这个HashMap
。
所以,这个查询返回了一个Cursor
,要理解这个场景的背后到底发生了什么。parseList
中会迭代它,然后得到Cursor
的每一行直到最后一个。对于每一行,它会创建一个包含这列的key和给对应的key赋值后的map。然后把这个map返回给这个解析器。
如果查询没有任何结果,parseList
会返回一个空的list。
下一步查询城市也是一样的方法:
val city = select(CityForecastTable.NAME)
.whereSimple("${CityForecastTable.ID} = ?", zipCode.toString())
.parseOpt { CityForecast(HashMap(it), dailyForecast) }
不同之处是:我们使用的是parseOpt
。这个函数返回一个可null的对象。结果可以使一个null或者单个的对象,这取决于请求是否能在数据库中查询到数据。这里有另外一个叫parseSingle
的函数,本质上是一样的,但是它返回的事一个不可null的对象。所以如果没有在数据库中找到这一条数据,它会抛出一个异常。在我们的例子中,第一次查询一个城市的时候,肯定是不存在的,所以使用parseOpt
会更安全。我又创建了一个好用的函数来阻止我们需要的对象的创建:
public fun <T : Any> SelectQueryBuilder.parseOpt(
parser: (Map<String, Any>) -> T): T? =
parseOpt(object : MapRowParser<T> {
override fun parseRow(columns: Map<String, Any>): T = parser(columns)
})
最后如果返回的city不是null,我们使用dataMapper
把它转换成domain object
再返回它。否则,我们直接返回null。你应该记得,lambda的最后一行表示返回值。所以这里将会返回一个CityForecast?
类型的对象:
if (city != null) dataMapper.convertToDomain(city) else null
DataMapper
函数很简单:
fun convertToDomain(forecast: CityForecast) = with(forecast) {
val daily = dailyForecast.map { convertDayToDomain(it) }
ForecastList(_id, city, country, daily)
}
private fun convertDayToDomain(dayForecast: DayForecast) = with(dayForecast) {
Forecast(date, description, high, low, iconUrl)
}
最后完整的函数如下:
fun requestForecastByZipCode(zipCode: Long, date: Long) = forecastDbHelper.use {
val dailyRequest = "${DayForecastTable.CITY_ID} = ? AND " +
"${DayForecastTable.DATE} >= ?"
val dailyForecast = select(DayForecastTable.NAME)
.whereSimple(dailyRequest, zipCode.toString(), date.toString())
.parseList { DayForecast(HashMap(it)) }
val city = select(CityForecastTable.NAME)
.whereSimple("${CityForecastTable.ID} = ?", zipCode.toString())
.parseOpt { CityForecast(HashMap(it), dailyForecast) }
if (city != null) dataMapper.convertToDomain(city) else null
}
另外一个Anko中好玩的功能我们在这里展示,那就是你可以使用classParser()
来替代我们用的MapRowParser
,它是基于列名通过反射的方式去生成对象的。我喜欢另一种方法因为我不需要使用反射并且还有控制权进行转换操作,但是在有时候可能对你有用。
保存一个forecast
saveForecast
函数只是从数据库中清除数据,然后转换domain
model为数据库model,然后插入每一天的forecast
和city forecast
。这个结构比之前的更简单:它通过use
函数从database helper
中返回数据。在这个例子中我们不需要返回值,所以它将返回Unit
。
fun saveForecast(forecast: ForecastList) = forecastDbHelper.use {
...
}
首先,我们清空这两个表。Anko没有提供比较漂亮的方式来做这个,但这并不意味着我们不行。所以我们创建了一个SQLiteDatabase
的扩展函数来让我们可以像SQL查询一样来执行它:
fun SQLiteDatabase.clear(tableName: String) {
execSQL("delete from $tableName")
}
清空这两个表:
clear(CityForecastTable.NAME)
clear(DayForecastTable.NAME)
现在,是时候去转换执行insert
后返回的数据了。在这一点上你可能直到我是with
函数的粉丝:
with(dataMapper.convertFromDomain(forecast)) {
...
}
从domain model
转换的方式也是很直接的:
fun convertFromDomain(forecast: ForecastList) = with(forecast) {
val daily = dailyForecast.map { convertDayFromDomain(id, it) }
CityForecast(id, city, country, daily)
}
private fun convertDayFromDomain(cityId: Long, forecast: Forecast) =
with(forecast) {
DayForecast(date, description, high, low, iconUrl, cityId)
}
在代码块,我们可以在不使用引用和变量的情况下使用dailyForecast
和map
,只是像我们在这个类内部一样就可以了。针对插入我们使用另外一个Anko函数,它需要一个表名和一个vararg
修饰的Pair<String, Any>
作为参数。这个函数会把vararg
转换成Android SDK需要的ContentValues
对象。所以我们的任务组成是把map
转换成一个vararg
数组。我们为MutableMap
创建了一个扩展函数:
fun <K, V : Any> MutableMap<K, V?>.toVarargArray():
Array<out Pair<K, V>> = map({ Pair(it.key, it.value!!) }).toTypedArray()
它是支持可null的值的(这是map delegate
的条件),把它转换为非null值(select
函数需要)的Array
所组成的Pairs
。不用担心就算你不完全理解这个函数,我很快就会讲到可空性。
所以,这个新的函数我们可以这么使用:
insert(CityForecastTable.NAME, *map.toVarargArray())
它在CityForecast
中插入了一个一行新的数据。在toVarargArray
函数结果前面使用*
表示这个array会被分解成为一个vararg
参数。这个在Java中是自动处理的,但是我们需要在Kotlin中明确指明。
每天的天气预报也是一样了:
dailyForecast.forEach { insert(DayForecastTable.NAME, *it.map.toVarargArray()) }
所以,通过map
的使用,我们可以用很简单的方式把类转换为数据表,反之亦然。因为我们已经新建了扩展函数,我们可以在别的项目中使用,这个才是真正可贵的地方。
这个函数的完整代码如下:
fun saveForecast(forecast: ForecastList) = forecastDbHelper.use {
clear(CityForecastTable.NAME)
clear(DayForecastTable.NAME)
with(dataMapper.convertFromDomain(forecast)) {
insert(CityForecastTable.NAME, *map.toVarargArray())
dailyForecast forEach {
insert(DayForecastTable.NAME, *it.map.toVarargArray())
}
}
}
在这一章中有很多代码被需要,所以你可以到代码库中查看检出。
Kotlin中的null安全
如果你正在使用Java 7工作的话,null安全是Kotlin中最令人感兴趣的特性之一了。但是就如你在本书中看到的,它好像不存在一样,一直到上一章我们几乎都不需要去担心它。
通过我们自己创造的亿万美金的错误对null的思考,我们有时候的确需要去定义一个变量包不包含一个值。在Java中尽管注解和IDE在这方面帮了我们很多,但是我们仍然可以这么做:
Forecast forecast = null;
forecast.toString();
这个代码可以被完美地编译(你可能会从IDE上得到一个警告),然后正常地执行,但是显然它会抛一个NullPointerException
。这个相当不安全的。而且按照我们的想法,我们应该去控制一切,随着代码的增长,我们会慢慢对某些null的控制。所以最终会得到很多的NullPointerException
或者丢失很多null检查(可能两者混合)。
可null类型怎么工作
大部分现代语言使用某些方法去解决了这个问题,Kotlin的方法跟别的相似的语言比是相当另类和不同的。但是黄金准则还是一样:如果变量是可以是null,编译器强制我们去用某种方式去处理。
指定一个变量是可null是通过在类型的最后增加一个问号。因为在Kotlin中一切都是对象(甚至是Java中原始数据类型),一切都是可null的。所以,当然我们可以有一个可null的integer:
val a: Int? = null
一个可nul类型,你在没有进行检查之前你是不能直接使用它。这个代码不能被编译:
val a: Int? = null
a.toString()
前一行代码标记为可null,然后编译器就会知道它,所以在你null检查之前你不能去使用它。还有一个特性是当我们检查了一个对象的可null性,之后这个对象就会自动转型成不可null类型,这就是Kotlin编译器的智能转换:
vala:Int?=null
...
if(a!=null){
a.toString()
}
在if
中,a
从Int?
变成了Int
,所以我们可以不需要再检查可null性而直接使用它。if
代码之外,当然我们又得检查处理。这仅仅在变量当前不能被改变的时候才有效,因为否则这个value可能被另外的线程修改,这时前面的检查会返回false。val
属性或者本地(val or var
)变量。
这听起来会让事情变得更多。难道我们不得不去编写大量代码去进行可null性的检查?当然不是,首先,因为大多数时候你不需要使用null类型。Null引用没有我们想象中的有用,当你想弄清楚一个变量是否可以为null时你就会发现这一点。但是Kotlin也有它自己的使处理更简洁的方案。举个例子,我们如下简化代码:
val a: Int? = null
...
a?.toString()
这里我们使用了安全访问操作符(?
)。只有这个变量不是null的时候才会去执行前面的那行代码。否则,它不会做任何事情。并且我们甚至可以使用Elvis operator(?:
):
val a:Int? = null
val myString = a?.toString() ?: ""
因为在Kotlin中throw
和return
都是表达式,他们可以用在Elvis operator操作符的右边:
val myString = a?.toString() ?: return false
val myString = a?.toString() ?: throw IllegalStateException()
然后,我们可能会遇到这种情景,我们确定我们是在用一个非null变量,但是他的类型却是可null的。我们可以使用!!
操作符来强制编译器执行可null类型时跳过限制检查:
val a: Int? = null
a!!.toString()
上面的代码将会被编译,但是很显然会奔溃。所以我们要确保只能在特定的情况下使用。通常我们可以自己选择作为解决方案。如果一份代码满篇都是!!
,那就有股代码没有被正确处理的气味了。
可null性和Java库
好了,前面的章节解释了使用Kotlin代码完美地工作。但是与普通的Java库和Android SDK会发生什么呢?在Java中,所有对象可以被定义为null。所以我们不得不处理大量潜在的在现实中不可能是null的null变量。这意味着我们的代码最后可能会有几百个!!
操作符,这绝对不是一个好的主意。
当我们去处理Android SDK时,你可能看见所有Java方法的参数被标记为单个的!
。比如,Java中在一些获取对象的方法在Kotlin中显示返回Any!
。这表示让开发者自己决定是否这个变量是否可null。
很幸运,新版本的Android开始使用@Nullable
和@NonNull
注解来辨别参数是否可以是null或者否个函数是否可以返回null。当我们怀疑时,我们可以进入源码去检查是否会接收到一个null对象。我的猜想是在以后,编译器能够读取这些注解,然后强制(或者至少是建议)一个更好的方法。
现在开始,当一个Jetbrains的@Nullable
注解(这个与Android的注解不同)被注解在一个非null的变量时,就会获得一个警告。相对的没有发生在@NotNull
注解上。
所以我们来举个例子,如果我们创建了一个Java的测试类:
import org.jetbrains.annotations.Nullable;
public class NullTest {
@Nullable
public Object getObject(){
return "";
}
}
然后在Kotlin中使用:
val test = NullTest()
val myObject: Any = test.getObject()
我们会发现,在getObject
函数上会显示一个警告。但是这只是从现在才开始的编译器检查,并且它还不认识Android的注解,所以我们可能不得不花更多的时间来等待一个更智能的方式。不管怎么样,使用源码注解的方式和一些Androd SDK的知识,我们也很难犯错误。
比如重写Activity
的onCraete
函数,我们可以决定是否让savedInstanceState
可null:
override fun onCreate(savedInstanceState: Bundle?) {
}
override fun onCreate(savedInstanceState: Bundle) {
}
这两种方法都会被编译,但是第二种是错误的,因为一个Activity很可能接收到一个null的bundle。只要小心一点点就足够了。当你有疑问时,你可以就用可null的对象然后处理掉用可能的null。记住,如果你使用了 !!
,可能是因为你确信对象不可能为null,如果是这样,请定义为非null。
这个灵活性在Java库中真的很有必要,而且随着编译器的进化,我们将可能看到更好的交互(可能是基于注解的),但是现在来说这个机制已经足够灵活了。
创建业务逻辑来访问数据
在实现访问服务器和与本地数据库交互之后,是时候把事情整合起来了。逻辑步骤如下:
- 从数据库获取数据
- 检查是否存在对应星期的数据
- 如果有,返回UI并且渲染
- 如果没有,请求服务器获取数据
- 结果被保存在数据库中并且返回UI渲染
但是我们的commands
不应该去处理所有这些逻辑。数据源应该是一个具体的实现,这样就可以被容易地修改,所以增加一些额外的代码,然后把command
从数据访问中抽象出来听起来是个不错的方式。在我们的实现中,它会遍历整个list直到结果被找到。
所以我们先来给接口定义一些我们实现provider
需要使用到的数据源:
interface ForecastDataSource {
fun requestForecastByZipCode(zipCode: Long, date: Long): ForecastList?
}
provider
需要一个接收zip code
和一个date
,然后它应该根据那一天返回一周的天气预报。
class ForecastProvider(val sources: List<ForecastDataSource> =
ForecastProvider.SOURCES) {
companion object {
val DAY_IN_MILLIS = 1000 * 60 * 60 * 24
val SOURCES = listOf(ForecastDb(), ForecastServer())
}
...
}
forecast provider
接收一个数据源列表,通过构造函数传入(比如用于测试),但是我设置了source的默认值为被定义在companion object
中的SOURCES
List。我将使用数据库的数据源和服务端数据源。顺序是很重要的,因为它会根据顺序去遍历这个sources,然后一旦获取到有效的返回值就会停止查询。逻辑顺序是先在本地查询(本地数据库中),然后再通过API查询。
所以主函数的代码如下:
fun requestByZipCode(zipCode: Long, days: Int): ForecastList
= sources.firstResult { requestSource(it, days, zipCode) }
它会得到第一个不是null的结果然后返回。当我在第18章中讲到的大量的函数操作符中搜索后,我没有找到完全符合我想要的。所以当我去查看Kotlin的源码时,我直接拷贝了first
函数然后修改它们来达到我想要的目的:
inline fun <T, R : Any> Iterable<T>.firstResult(predicate: (T) -> R?) : R {
for (element in this){
val result = predicate(element)
if (result != null) return result
}
throw NoSuchElementException("No element matching predicate was found.")
}
该函数接收一个断言函数,它接收一个T
类型的对象然后返回一个R?
类型的值。这表示predicate
可以返回null类型,但是我们的firstResult
不能返回null。这就是为什么返回R
的原因。
它怎么工作呢?它将遍历集合中的每一个元素然后执行这个断言函数。当这个断言函数的结果返回不是null时,这个结果就会被返回。
如果我们可以允许sources返回null,那我们就可以使用firstOrNull
函数来代替。不同之处就是最后一行的返回null和抛异常。但是我现在不在代码里面去处理这些细节了。
在我们的例子中T = ForecastDataSource
,R = ForecastList
。但是记住在ForecastDataSource
中指定的函数返回一个ForecastList?
,也就是R?
,所以一切都是匹配得这么完美。requestSource
让前面的函数看起来更有可读性:
fun requestSource(source: ForecastDataSource, days: Int, zipCode: Long):
ForecastList? {
val res = source.requestForecastByZipCode(zipCode, todayTimeSpan())
return if (res != null && res.size() >= days) res else null
}
如果结果不是null并且数量也参数匹配,那这个查询被执行且只会返回一个数据。否则,数据源没有足够的数据来返回一个成功的结果。
函数todayTimeSpan()
计算今天毫秒级的时间,并排除掉“时差”。其中一些数据源(我们例子中的数据库)可能会需要它。因为如果我们没有指定更多的信息,服务端默认就是今天,所以我们不需要设置它。
private fun todayTimeSpan() = System.currentTimeMillis() / DAY_IN_MILLIS * DAY_IN_MILLIS
这个类完整的代码如下:
class ForecastProvider(val sources: List<ForecastDataSource> =
ForecastProvider.SOURCES) {
companion object {
val DAY_IN_MILLIS = 1000 * 60 * 60 * 24;
val SOURCES = listOf(ForecastDb(), ForecastServer())
}
fun requestByZipCode(zipCode: Long, days: Int): ForecastList
= sources.firstResult { requestSource(it, days, zipCode) }
private fun requestSource(source: RepositorySource, days: Int,
zipCode: Long): ForecastList? {
val res = source.requestForecastByZipCode(zipCode, todayTimeSpan())
return if (res != null && res.size() >= days) res else null
}
private fun todayTimeSpan() = System.currentTimeMillis() /
DAY_IN_MILLIS * DAY_IN_MILLIS
}
我们已经定义了一个ForecastDb
。现在我们需要去实现ForcastDataSource
:
class ForecastDb(val forecastDbHelper: ForecastDbHelper =
ForecastDbHelper.instance, val dataMapper: DbDataMapper = DbDataMapper())
: ForecastDataSource {
override fun requestForecastByZipCode(zipCode: Long, date: Long) =
forecastDbHelper.use {
...
}
...
}
ForecastServer
还没有还被实现,但是这是非常简单的。它在从服务端接收到数据之后就会使用ForecastDb
去保存到数据库。用这种方式,我们就可以缓存这些数据到数据库中,提供给以后的查询。
class ForecastServer(val dataMapper: ServerDataMapper = ServerDataMapper(),
val forecastDb: ForecastDb = ForecastDb()) : ForecastDataSource {
override fun requestForecastByZipCode(zipCode: Long, date: Long):
ForecastList? {
val result = ForecastByZipCodeRequest(zipCode).execute()
val converted = dataMapper.convertToDomain(zipCode, result)
forecastDb.saveForecast(converted)
return forecastDb.requestForecastByZipCode(zipCode, date)
}
}
它也是使用了之前我们创建的data mapper
,最然我们修改一些函数的名字来让它更加与我们之前用在database model
的mapper更相似。你可以查看provider
来查看细节。
被重写的方法用来请求服务器,转换结果到domain objects
并保存它们到数据库。它最后查询数据库返回数据,这是因为我们需要使用到插入到数据库中的字增长id。
这就是provider
被实现的最后的一步了。现在我们需要开始使用它。ForecastCommand
不会再直接与服务端交互,也不会转换数据到domain model
。
RequestForecastCommand(val zipCode: Long,
val forecastProvider: ForecastProvider = ForecastProvider()) :
Command<ForecastList> {
companion object {
val DAYS = 7
}
override fun execute(): ForecastList {
return forecastProvider.requestByZipCode(zipCode, DAYS)
}
}
其它修改的地方包括重命名和包的结构调整。在Kotlin for Android Developers repository查看相应的提交。