Andorid Content Provider
v1.0 1st Edition
前言
Android ContentProvider属于Android框架中的几个核心组件,相信Android的一些入门书籍都会提起。ContentProvider的主要用途我认为是对一些结构化数据进行管理,而对于数据管理的实现也有非常多,因此在实际应用中要自行取舍。本文主要从以下几个方面对ContentProvider进行讲解,如果遇到TL;DR可直接跳转。
- ContentProvider概念
- ContentProvider使用
- 各种问题
- Loader/AsyncQuery
- Observer
一、ContentProvider概念
1 | public abstract class ContentProvider{ |
如果要实现自己的ContentProvider的话,需要继承android.content.ContentProvider
抽象类,继承之后必须覆盖的抽象方法如上面的代码。这里的几个方法作用如下:
ContentProvider#onCreate():boolean
:构造方法,由框架调用运行在UI线程中,因此不能做一些耗时操作,一般用作属性初始化(比如数据库初始化)。ContentProvider#getType(Uri uri):String
:返回的是Uri对对应的资源的MIME类型。ContentProvider#query():Cursor
:使用条件查询,最基本的也是最常用的方法,其返回一个数据库游标。ContentProvider#insert():Url
:插入一条数据,返回插入数据资源定位符。ContentProvider#delete():int
:使用给定的条件删除数据库信息,返回受影响的记录数。ContentProvider#update():int
:使用给定的条件更新数据库信息,返回受影响的记录数.
之后需要在AndroidManifest.xml
中进行声明。如下:
1 | <provider |
这里需要特别注意两个地方,authorities
的值为Provider对外暴露的Uri地址,其必须保证全系统为唯一性。即无论你应用内部还是整个Android系统中都不能出现两个authorities值相同的Provider
声明,关于这个值的命名有什么标准我也没注意过,既然是全局唯一那么自然和包名有相似的规则吧。而这里的name
属性则是真正的Provider类的位置,当然了authorities和name可以一致,但是问题是这个Uri是完全暴露出去的如果包名比较长不好看写起来麻烦是一回事,总觉得是比较敏感的东西被暴露了。所以这里我觉得还是使用功能和逻辑命名比较好,如果你只有一个功能单一的Provider那我觉得直接按照包名就好了。
// TODO multiprocess
关于数据存储的选择,应用内数据存储对于简单的数据格式来说
SharedPreferences
无疑是最好的选择。但是其存在一个明显的问题无法跨进程使用,当单个应用使用多个进程实例,此时虽然可以保证Application
对象的唯一性,但是其会进行多次初始化。即便全局唯一实例的SharedPreferences
对多次实例化做了判断,但是其方法调用结果依旧会在不同进程间存在数据混乱。
二、ContentProvider使用
使用离不开场景,这里假设要实现一个类似Wiki
功能的应用。基本功能就是向外提供数据查询,之前也说过对于结构化数据SQLite的存储是非常方便的,而结合了ContentProvider
之后又实现了非常好的数据获取的解耦。相比直接使用SQLiteDatabase
或外面在封装一层来说不知道高到哪里去了,当然了大部分场景中ContentProvider都是基于SQLite的,所以这又回到了一个本质的问题 — 数据库设计。
对于数据库设计个人觉得移动设备完全没有必要走和服务器一样的结构,特别是字段命名。很多手机数据库设计都是基于服务端API的返回格式来设计的,这里有一个我非常不能忍的问题就是字段。经过长时间迭代API经过频繁的变动之后,返回字段名很可能基本就不是英文字母了,各种不看文档觉得莫名其妙的名称,各种拼写错误在IDE中一个一个警告,每次看到都觉得莫名的烦躁。很多时候你还要各种get和set这种字段,但是看命名完全不知所以然和你本地逻辑没有一个良好的对应。所以都去见鬼吧,自己设计吧(这段就当没看到,划掉好了)。
数据库设计
这是一个卡牌游戏的卡面WIKI,为了简化期间这里设计一个卡牌的数据表。
1 | public class CardInfo { |
首先这是一个CardInfo类是一个简单的卡牌实体类,如果对数据库有稍许了解的话可以简单的想象一个对象为一条数据表中的记录。此对象的几个私有变量为卡牌的逻辑属性,其中定义的Impl
类则为实际的数据表字段名。Impl
继承BaseColumns
的原因是需要获得其接口中的_ID
属性,其另外的_COUNT
字段这里没有用到。
此外CardInfo
这里只是简单的几个属性用于和数据表中的字段进行对应,并且实际上这个类合并了其他的几个功能。比如本质上CardInfo
是一个逻辑相关的类,如果真的用起来的话尽量把数据表定义内容分离出去,此外可能还有一些工具方法,如游标和对象/ContentValues之间的转换等。这都需要根据实际的逻辑、数据库和易用性等等进行综合考量决定。当然了这样做也只是适合在本应用中使用,如果向外提供数据接口那么就另说了。
1 | public class DatabaseHelper extends SQLiteOpenHelper{ |
接下里是数据库具体的操作类,主要作用看其中的几个方法就一目了然了。其构造方法进行数据库初始化设置,比如名字,版本号等。接下来如果指定的数据库不存在则调用onCreate
方法,在此方法中对数据表进行初始化操作,如建表和插入初始化数据。如果数据库已存在而版本号不同这会调用onUpGrade
方法对数据库进行升级,这里一般会对表结构和旧数据进行清理等(当然了直接删除旧表也不是不可以…)。
1 | public class CoreProvider extends ContentProvider{ |
Provider如果完善起来实际上是相当的繁琐的,毕竟要面对各种数据库操作请求和兼容Uri规范。所以这里进行逐一说明,就目前我的感觉来说使用Provider对数据库进行操作有两种思路,一种是直接使用SQL的操作思路,这点也是最直观最有效的,另一种就是使用android的Uri思路了。接下来分别使用SQL和Uri两种思维方式进行描述。
首先进行数据初始化:
insert into card_info (_card_id, _name, _character_id, _attribute, _explain) values (100243, "Futaba Anzu", 134, "cute", "每 11 秒,有 40 ~ 60% 的几率使所有PERFECT音符恢复你 3 点生命,持续 4 ~ 6 秒。")
insert into card_info (_card_id, _name, _character_id, _attribute, _explain) values (200165, "Takagaki Kaede", 197, "cool", "每 11 秒,有 40 ~ 60% 的几率获得额外的 15% 的COMBO加成,持续 5 ~ 7.5 秒。")
insert into card_info (_card_id, _name, _character_id, _attribute, _explain) values (300125, "Jougasaki Mika", 267, "passion", "每 9 秒,有 40 ~ 60% 的几率使所有PERFECT/GREAT音符获得 17% 的分数加成,持续 4 ~ 6 秒。")
当然以上语句可以写在DatabaseHelper#onCreate
方法中进行初始化。
使用SQL操作数据库
2.1 查询操作
标准的SQL简单来说就是对表中的字段进行操作,所以从这点上来说完全要匹配ContentProvider
给出的几个方法,尽可能的使用拼装SQL的思路。
query(uri:Uri, projection:String[], selection:String, selectionArgs:String[], sortOrder:String ):Cursor
:首先是查询方法,它有五个参数之多。但是对于了解SQL语句的朋来说,其实这是不够的,即便和SQLiteDatabase
的查询方法相比也是不足的。这五个参数的意义分别为:
uri:Uri
:Uri地址,这个东西实际是非常灵活的,同时也是唯一一个可以灵活处理的参数。projection:String[]
:列名数组,实际为查询结构需要返回的哪些列。selection:String
:选择列表,拿SQL来说它就是where后面的条件部分。selectionArgs:String[]
:参数列表,实际上是selection的预编译部分,如果直接全部写在selection中,则这部分可以省略。sortOrder:String
:结果集排序。
使用SQL语句的思路就是传过来表名、结果集列名、插叙条件和结果集排序条件,这是最基本的SQL查询语句,所以一些像什么多表连接查询在ContentProvider中就别想了,但是话又说回来如果你用对Provider的控制权那么又有什么办不到呢。你只需要把足够的条件想办法传进来,然后SQLiteDatabase拥有足够强大的功能去实现你想要的查询。
现在我需要查询_attribute
(属性/阵营)为passion
的卡,那么SQL语句直观来说可以这么写select * from card_info where _attribute = 'cute'
(为了看起来美观我就不加分号了)结果如图。使用Provider的通用接口的话就感觉比较繁琐了。
1 | ContentResolver resolver = getContentResolver(); |
这里直接取游标的话写起来非常的繁琐,那么在这里把这一串写成一个工具方法。并且还有游标的开闭回收问题,这里就一起做了。
1 | public final class ProviderHelper { |
这里只有整型和字符串,简化期间就只写了两个get方法。另外提供一个close方法关闭游标,当然也可以实现其他的close方法用于关闭输入输出流等,这都是非常常用的功能。另外使用ignored
做Exception
变量名,可以在IDEA家族中规避掉控catch带来的警告,每次写完都觉得心情格外舒畅。那么使用工具方法之后上面的这个流程重写如下:
1 | ContentResolver resolver = context.getContentResolver(); |
注意游标操作时候的异常可不要忽略掉,要保留下日志以便分析问题。当然以上读游标的内容如果依旧觉得非常繁琐的话,完全可以再写一个工具方法用于游标到对象的转换,比如写一个CardInfo#fromCursor(cursor:Cursor)
方法,然后把结果存入一个列表返回。到这里就可以很清楚的看到这种写反本质就是select ... where ... order
结构,而参数列表和排序是可以忽略不计,但是就我而言args
参数用起来远比直接拼凑字符串来的好,但是这使用预编译参数替换问号占位符的方法实际和Java中的JDBC还是有一定的差距的,这在下面的问题中说明。
2.2 修改
insert(uri:Uri, values:ContentValues):Uri
和update(uri:Uri, values:ContentValues, selection:String, selectionArgs:String[]):int
与delete(uri:Uri, selection:String, selectionArgs:String[]):int
。所谓增删改查CUDI,虽说查询是使用频率最高的操作,单修改的重要性依旧不言而喻。从某种程度上说update
和delete
操作存在一定的相似度,比如都有限制条件,当然这些条件依旧是可以忽略的。
而insert
和update
又同时包含了android.content.ContentValues
,而ContentValues内部实现目前到API 23(Android 6.0)为止都为HashMap
,所以它本质就是一个Key-value格式的数据集合,key为列名value为列的值。这几个方法都是Uri提供表名ContentValues提供数据。
对于新增操作,拿最开始初始化数据库时的数据来说,可以写成以下的形式:
1 | // Insert 新增记录 |
这里和直接使用SQL语句是一样的,不要使用CardInfo#Impl#_ID
字段,因为这个字段在建表的时候已经声明过为自动增长ID,如果实际使用用无法确定是不是存在ID字段,那么最好在插入前删除一次,从而避免不必要的错误。
update/delete没有什么多说的,其存在一个where
参数和查询一个思路。
使用Uri操作数据库
// TODO 补充
使用Uri的方式操作数据库我用的也比较少,毕竟如果自己写一个Provider要做这些确实非常繁琐,而且感觉不够灵活。但是话又说回来,如果这样做了之后用起来真是神清气爽,无论是视觉上还是调用起来真是一种享受(滑稽)。
这简要的介绍一下Android中的Uri。android的Uri就是一个资源定位符(Universal Resource Identifier),如果和URL做比较的话其最大的特色是“scheme”,说实话我第一次看到各种Uri前缀的时候真的一时间没反应过来,个人觉得把这部分看做URL中的协议部分后就比较直观了。
Uri的结构为:scheme://host:port/path的结构,但是不同的scheme其后的标示符也不同。比如文件为file:///、电话tel:xx、定位gps:xx等。同时host加上port部分也叫做authority
,就是ContentProvider中的authority。
说到这里其实上一部分虽然给出一个CUDI的例子,但是其对应的ContentProider并没有实现细节。接下来在首先实现上一部分内容的功能后并提供有限的Uri操作支持,更多的支持可能在将来进行完善和补充(大概…)。
1 | public class CoreProvider extends ContentProvider{ |
这里使用UriMatcher
定义一些可接受的操作,首先是完全开放状态的CODE_CARD
,其接受结尾为CardInfo.TABLE_NAME
的所有Uri。何其相对的是CODE_CARD_ID
,它接受CardInfo.TABLE_NAME
后追加一个路径的Uri,由于这只接受追加的路径为ID属性,所以传入其他内容无法保障获取正确的结果。另外这里只使用一套操作码来用于CUDI操作,
query():Cursor
首先为了避免一些特殊情况可能造成的误解,这里增加一张角色信息表CharacterInfo
,这个类中占有表名和Uri属性只是为了说明存在另一张表的情况,不做任何相关操作。
在使用UriMatcher
获取操作码之后再进行下一步操作,这里为了简化操作在switch语句中获取相关操作对应的表明。这里分为两种操作情况,直接SQL语句的时候只需要获得表名然后将所有剩余参数全部传入database.query():Cursor
方法(实际使用中可能还要防止SQL注入等)。另外就是直接使用Uri的方式,这种方式要考虑的比较多这里目前先给出一个最为简单情况,就是指提供ID查询。使用ContentUris.parseId(uri:Uri):long
方法截取ID字段,随后将获得ID字段拼凑出selection和预编译的内容再次传入database.query():Cursor
方法。
//TODO 更加通用的Uri操作
insert update datele
这里没有什么多说的,都是要么截取表名要么截取ID(此时的表名其实是由操作码判断出来的)。由于最终的数据库操作都是使用SQLiteDatabase
来完成,所以任何形式本质上都是凑齐SQLiteDatabase几个相关方法的参数而已。任何外部的参数传递方式也只是手段不同,在ContentProvider中的使用都没有什么本质的区别。对于insert
来说其返回一个Uri,这个Uri的返回是非常有必要的,不要为了觉得麻烦直接返回一个null
,并且以为只要不抛出异常那必定是成功的,所以返回和不返回意义大部的(这样想其实是非常错误的)。在SQLiteDatabase#insert(...):long
方法执行成功后会返回一个行号,此行号默认是一个从数字1开始步长为1的自增字段。同时默认情况下数据表设置的自增ID也是从数字1开始以1为步长自增,所以即便原表格旧记录被删除行号和ID依旧保持同样的增长,因此insert
方法返回的ID我们可以等同于自己设置的数据表自增ID(这里就是Card_info#Impl#_ID
)。于是返回的Uri这里追加插入成功的ID,即ContentUris.withAppendedId(uri, rowId)
,并向外发送新增数据成功的通知。这种通知可以为数据驱动类逻辑提供可靠保障,关于此类通知的详细说明参见下文的通知部分。
// TODO 批量
2.3 Notification,回调通知
1 | private void notifyChange(Uri uri){ |
(真是惊了,现在各种@Nullable
各种报可能出现空指针警告)
现在我们增加一条回调通知供相应的观察值使用。如上文的方法本质是调用ContentResolver#notifyChange(uri:Uri,observer:ContentObserver)
,而观察者的注册是通过ContentResolver#registerContentObserver(uri:Uri,notifyForDescendents:boolean,observer:ContentObserver)
。
通知一方可以采取全表通知的形式如CardInfo#CARD_CONTENT_URI
,也可采取记录式的如ContentUris.withAppendedId(uri, rowId)
。第一种我们在收到通知后仅仅知道表格数据发生了变化,但是如果需要获得变化的数据还需要做更多的操作。而第二种方法增加了ID属性,我们在获得通知的时候就可以知道是哪条记录发生了变化ContentUris.parseId(uri)
。这时候我们甚至可以直接使用此Uri调用查询方法获得变化记录的详细情况,这对于UI界面来说相当于局部刷新。
三、问题
- rawSQL
- 预编译
四、Loader/AsyncQuery
在使用ContentProvider
时由于我们无法得知数据的实际来源和数据操作的复杂度,如可能是读磁盘甚至是读网络,亦或有比较耗时的计算,所以我们无法保障数据返回的时间。因此就有必要使用异步查询来将耗时操作切换至后台线程,并在执行完成后切回到UI线程。对于直接使用Thread/Runnable - Handler
这种情况实际上考虑的事情非常多,最关键的是写出来不优雅,用起来难受。
这里介绍两种使用ContentProvider
放方式,分别是Loader/LoaderManager和AsyncQuery。
4.1 Loader
Loader
和LoaderManager
我记得在第一次看的时候还是比较吃惊的,最主要的是生命周期管理和字段的线程切换。但是后来我越来越觉得这个东西越来越不顺眼,这点有一下几个原因:1、API设计的不是很直接明了,文档写的也不怎么样第一次用总觉得比较莫名。2、繁琐,感觉要写一堆东西。但是话又说回来了,好像都比较繁琐的样子… 3、感觉有点重…
1 | public class LoaderActivity extands AppCompatActivity implements LoaderManager.LoaderCallbacks<Cursor>{ |
如果继承AppCompatActivity
那么LoaderCallbacks
就要使用support v4中的版本否则直接使用即可,getSupportLoaderManager()
同理。这里我想说的是LoaderCallbacks
是一个借口,我有时候是非常不写换借口的。因为借口设计之初的目的就是多继承,正因为如此多继承经常被滥用。经常觉得借口好呀,不用强制再写个类,这样导致了想Activity
这样的类在不知不觉中implements
关键字后面跟了一大串借口。进一步演变之后就变成了各种放些堆积在Activity
中,越来越烦躁。对于ContentProvider
来说直接在onCreateLoader
构造一个CursorLoader
即可,用法和ContentResolver
非常相似,这里也没什么多说的。
4.2 AsyncQuery
AsyncQuery即AsyncQueryHandler
,需要注意的是其本身是继承android.os.Handler
的。其源码相对来说是比较少的,其内部使用android.os.HandlerThread
来构建的线程并创建一个WorkerHandler
用来异步执行数据操作。
1 | public class LoaderHandler extends AsyncQueryHandler{ |
调用的话它提供了startQuery()
、startInsert
、startUpdate
、startDelete
几个入口方法,返回分别在对应的xxComplete
方法中。而对于同类操作方法参数中的token
用来做区分,而cookie
则作为一个附加对象跟随整个操作过程。
我想说的是其提供的几个方法条理非常的清晰一目了然,即便你第一次用,在IDE里面代码提示点出来方法或者展开Override/Implement
也能大概对这个类的用法猜出来个123,再点开它的源代码大致浏览一下,妥妥的。
和Loader
的时候说的一样,接口在和UI在一起用的时候很容易将代码逻辑过多的丢到UI中去,也许这是一个不自觉地的过程,但是当意识到UI中逻辑太混乱的时候又有多少人愿意仔细梳理一遍,各种重构呢?就比如各种xxListener
、xxCallback
等等,如果这个接口是自己设计的话那可能问题就更多了,所以从这方面来说我相对于接口我更倾向于抽象类的原因,因为Java的单继承特性,对于一个抽象类在使用之处你就会去考虑他的实现怎么做才更好。