文章目录
简介
ContentProvider主要用于在不同的应用程序间实现数据共享的功能,允许一个程序访问另外一个程序中的数据,还能保证数据访问的安全性。
是Android跨进程实现数据共享的标准方式。
- ContentProvider相当于进程间的搬运工,对数据一系列的操作(CRUD)
- 数据源可以是数据库(SQLite等)、文件、xml、网络等等。
在了解ContentProvider之前,需要对URI和运行时权限进行回顾下,在ContentProvider中涉及到这两个知识点
统一资源标识符(URI)
URI:是给ContentProvider中的数据提供唯一的标识符。
URI分为自定义和系统内置两种类型,系统内置比如通讯录、日程表、相册等等。
自定义URI
自定义URI的标准命名:
- 主题名(Schema):ContentProvider的URI前缀,系统规定的。
- 授权信息(Authority):ContentProvider的唯一标识符。
- 表名(Path):ContentProvider指向数据库中的某个表名。
- 记录(ID):表中的某个记录,如果无指定,默认返回的是全部记录。
注意:
自定义URI格式末尾一般有两种结束方式
一种是以表名(Path)即路径为结束点,该方式表示访问表中所有的的数据
如:content://com.hzw.progress/Book
另外一种是记录(ID)为结束点,则表示只访问有相应ID属性的数据
如:content://com.hzw.progress/Book/1
此外某些时候我们希望访问任意一张表中所有数据或者某张表的任意一行数据,此时可以使用通配符的方式。
// * 匹配任意长度的任何有效字符的字符串
// 表示能访问com.hzw.progress下所有表数据
content://com.hzw.progress/*
// # 匹配任意长度的数字字符的字符串
//表示能访问com.hzw.progress下的表名为Book中任意一行数据
content://com.hzw.progress/Book/#
自定义的URI的使用方法:
//通过Uri解析指定的Uri字符串得到URi对象
Uri uri = Uri.parse("content://com.hzw.progress/Book/1");
系统内置URI
关于系统内置常用的URI,可以参考这篇文章Android权限Uri用法
在使用内置URI时,需注意在Mainfest文件中声明相应的权限,否则会访问失败甚至程序崩溃现象。具体的所需权限可以搜索log日志的“Permission Denial”得知。
####运行时权限
在Android6.0开始系统加入运行时权限机制,就是在应用安装使用中需要用户同意一些系统权限,若拒绝授权,则无法使用该功能。通常这些权限是涉及到用户的安全和隐私,为此称之为危险权限,一些常用的危险危险:
下面通过调起手机系统拨号为例,说明权限授权的流程
在未手动授权的情况:
public void onClick(View view) {
switch (view.getId()){
case R.id.but_call:
call();
break;
}
}
private void call() {
try {
Intent intent = new Intent(Intent.ACTION_CALL);
intent.setData(Uri.parse("tel:10086"));
startActivity(intent);
}catch (SecurityException e){
e.printStackTrace();
}
}
//在Mainfest中声明打电话权限
<uses-permission android:name="android.permission.CALL_PHONE"/>
在Android5.0之前只要在Mainfest声明权限就可以访问成功,然而在Android6.0及以上环境,点击后会发现程序没有任何反应,说明调起拨号界面失败,我们可以通过日志发现具体错误信息:
通过Permission Denial搜索日志也可得知具体所需权限
接下来修复处理这个运行时权限问题,实现调起拨号界面。
基本步骤:
- 获取所需权限的授权许可值,通过checkSelfPermission可得。具体返回值有两种:
- PERMISSION_GRANTED:表示已经得到相关权限
- PERMISSION_DENIED:未得到权限许可
- 通过授权许可值,检查是否有授权(与PackageManager.PERMISSION_GRANTED进行比较)
- 未授权:则申请授权,可用requestPermissions申请
- 已有授权:直接调用业务方法。
- 重写onRequestPermissionsResult方法,在其根据grantResults数组的元素值和请求code判断是否授权成功。
- 授权成功:可直接调用业务方法
- 授权失败或拒绝授权:下次再次申请授权时需前往应用信息界面手动打开需求
private static final int CALL_CODE=100;
public void onClick(View view) {
switch (view.getId()){
case R.id.but_call:
//获取CALL_PHONE权限许可值
int selfPermission = ActivityCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE);
//检查是否授权
if (selfPermission !=PackageManager.PERMISSION_GRANTED){
//没有授权情况下,则申请CALL_CODE权限
ActivityCompat.requestPermissions(this,new String[]{Manifest.permission.CALL_PHONE},CALL_CODE);
}else {
//授权成功的情况下,直接调用call方法
call();
}
break;
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
//根据grantResults数组的元素值和请求code判断是否授权成功
if (grantResults.length>0 && grantResults[0] == PackageManager.PERMISSION_GRANTED){
switch (requestCode){
case CALL_CODE:
call();
break;
}
}else {
Log.i(TAG, "onRequestPermissionsResult: "+"权限授权失败,需要前往应用信息页面设置");
showPermissionFailureDialog();
}
}
//授权失败显示的dialog
private void showPermissionFailureDialog(){
new AlertDialog.Builder(this)
.setMessage("您已拒绝了相关权限,将无法使用该功能,是否前往应用信息页面手动打开该权限")
.setNegativeButton(android.R.string.no,null)
.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
Intent intent=getAppDetailSettingIntent(ContentProviderActivity.this);
startActivity(intent);
}
}).create().show();
}
/**
* 获取应用详情页面intent
*
* @return
*/
public static Intent getAppDetailSettingIntent(Context context) {
Intent localIntent = new Intent();
localIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
localIntent.setAction("android.settings.APPLICATION_DETAILS_SETTINGS");
localIntent.setData(Uri.fromParts("package", context.getPackageName(), null));
return localIntent;
}
以上代码运行后初次点击,会弹出一个授权提示对话框选择
接着点击允许,可直接拨号
如果点击失败,则会输出失败的log日志
以上运行时申请授权的基本流程。
ContentProvider核心方法
进程间共享数据的本质就是CRUD(增删改查),而ContentProvider正好也提供对应的方法。
public class BookProvider extends ContentProvider{
private static final String TAG = "BookProvider";
/**
* ContentProvider初始化被调用
* 一般用于创建数据库或者升级等操作,只有在ContentResolver访问的时候,才会触发onCreate方法
* 此方法是主线程执行,不能做耗时操作
* @return true:ContentProvider初始化成功,false则失败
*/
@Override
public boolean onCreate() {
Log.d(TAG, "onCreate: 当前线程"+Thread.currentThread());
return false;
}
/**
* 插入一条新数据(添加数据)--增
* @param uri 根据uri插入到具体哪张数据表
* @param values ContentValues底层是key-value键值对结构,使用HashMap实现的
* key:表示列名,value:表示行名,如果value为空,在表中则是空行,无内容
* @return 添加成功后,返回这条新数据的uri。
*/
@Nullable
@Override
public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
Log.d(TAG, "insert: 当前线程"+Thread.currentThread());
return null;
}
/**
* 删除数据 -- 删
* @param uri 根据uri删除哪张表的数据
* @param selection 根据条件删除具体哪行数据
* @param selectionArgs 与selection类似
* @return 返回被删除的行数。
*/
@Override
public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
Log.d(TAG, "delete: 当前线程"+Thread.currentThread());
return 0;
}
/**
* 更新数据 -- 改
* @param uri 根据Uri修改具体哪张表的数据
* @param values 与insert的ContentValues一样,key是列名,若传入的value为空,则会删除原来的数据置空。
* @param selection 选择符合该条件的行数据进行修改
* @param selectionArgs 与selection类似
* @return 更新的行数
*/
@Override
public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) {
Log.d(TAG, "update: 当前线程"+Thread.currentThread());
return 0;
}
/**
* 查询数据 -- 查
* @param uri 根据uri查询具体哪张数据表
* @param projection 确定查询表中哪些列 ,传null则返回所有的列
* @param selection 确定查询哪行,传null这返回所有的行
* @param selectionArgs 与selection类似
* @param sortOrder 用于对查询结果进行排序,传null则使用默认的排序方式,也可以是无序的。
* @return 查询的返回值,是个Cursor对象,在取完数据需进行关闭。否则会内存泄漏
*/
@Nullable
@Override
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) {
Log.d(TAG, "query: 当前线程"+Thread.currentThread());
return null;
}
/**
* 返回指定内容URL的MIME类型
* @param uri 具体Url
* @return MIME类型 比如图片、视频等等,可直接返回null
*/
@Nullable
@Override
public String getType(@NonNull Uri uri) {
return null;
}
}
MIME类型命名规范
- 必须以vnd为开头
- 如果URI以路径为结尾,则后接android.cursor.dir/,如果URi以记录id为结尾,则后接android.cursor.item/
- 最后接上vnd.<授权信息Authority>.<路径Path>
示例:
//URi以路径Path为结尾:content://com.hzw.progress/Book
vnd.android.cursor.dir/vnd.com.hzw.progress.Book
//URi以id为结尾:content://com.hzw.progress/Book/1
vnd.android.cursor.dir/vnd.com.hzw.progress.1
注意:
1、onCreate方法是在主线程中运行,不能做耗时操作
2、query、insert、delete、update四个核心方法是运行在Binder线程中,并不是主线程
3、当线程并发时,需在核心方法中处理同步问题。
在Mainfest文件中声明注册ContentProvider
<provider
android:authorities="com.hzw.progress"
android:name=".provider.BookProvider"
android:permission="com.hzw.PROVIDER" //包括全部权限
android:process=":provider"/>
- authorities:ContetProvider唯一标识
- permission:声明访问ContentProvider需要的权限,一般是另一外应用需要访问该ContentProvider需要声明该权限,否则外界应用会异常终止。
- process:开启一个新进程
关于权限,可分别声明读写的所需不同的权限
android:writePermission="" 写权限
android:readPermission="" 读权限
android:permission="" 全部权限
外部(Activity)调用
Uri uri = Uri.parse("content://com.hzw.progress");
getContentResolver().query(uri, null, null, null, null);
getContentResolver().query(uri, null, null, null, null);
getContentResolver().query(uri, null, null, null, null);
ContentProvider基本使用
ContentProvider的使用主要从系统内置、自定义这两方面深入了解
- 系统内置的ContentProvider,比如Android的通讯录、日程表、相册等等,很多。
- 自定义ContentProvider,分为两种进程间和进程内,必须重写上面的6个方法。
在使用ContentProvider之前,必须对ContentResolver这个类进行了解下,因为ContentProvider 类并不会直接与外部进程交互,而是通过 ContentResolver 类。
###ContentResolver内容解析器
ContentResolver是个抽象类,具体实例是Content中就已经初始化了,也就是说应用一启动就初始化了。
public abstract class ContentResolver {
}
ContentResolver的作用:统一管理不同的ContentProvider间的操作,ContentResolver提供了与ContentProvider一样的增删改查方法。
// 外部进程向 ContentProvider 中添加数据
public Uri insert(Uri uri, ContentValues values)
// 外部进程 删除 ContentProvider 中的数据
public int delete(Uri uri, String selection, String[] selectionArgs)
// 外部进程更新 ContentProvider 中的数据
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs)
// 外部应用 获取 ContentProvider 中的数据
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
String sortOrder)
系统内置ContentProvider的使用
下面以获取手机通讯录信息为例,先得到ContentResolver对象,紧接着调用其query方法,注意需要READ_CONTACTS授权
public class ContentProviderActivity extends AppCompatActivity{
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_content_provider);
}
private static final int CALL_CODE=100;
private static final int CONTACTS_CODE=200;
public void onClick(View view) {
switch (view.getId()){
case R.id.but_contacts:
//检查是否授权成功
int checkSelfPermission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS);
if (checkSelfPermission!=PackageManager.PERMISSION_GRANTED){
ActivityCompat.requestPermissions(this,new String[]{Manifest.permission.READ_CONTACTS},CONTACTS_CODE);
}else {
//读取手机联系人
readContacts();
}
break;
}
}
//读取手机联系人
private void readContacts(){
//通过ContentResolver对象,查询所有的联系人
Cursor cursor = getContentResolver().query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, null, null, null);
try {
if (cursor!=null){
//遍历Cursor ,读取所有的信息
while (cursor.moveToNext()){
//联系人姓名
String contactName = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME));
//联系人号码
String contactPhone = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER));
Log.i(TAG, "联系人姓名:"+contactName+" 号码:"+contactPhone);
}
}
}catch (SecurityException e){
e.printStackTrace();
}finally {
//关闭光标cursor,避免内存泄漏
if (cursor!=null){
cursor.close();
}
}
}
//关于权限的代码和上面是一样的,这里就不贴下
……………………
}
输出结果:
自定义ContentProvider的使用
所谓自定义ContentProvider就是继承于ContentProvider类,重写核心方法,一般用于应用间的数据共享,就是我们常说的使用ContentProvider进行进程间通信,其实访问系统内置的ContentProvider也属于进程间访问,一般应用内很少使用ContentProvider进行数据共享,在应用内进行数据共享有很多其他更便捷的方式,比如file文件、sp、db等等,如果在应用内使用ContentProvider数据共享感觉有点大题小做。
基本使用步骤,可大体分以下三个步骤:
- 设计数据存储方式
- 可选择文件和数据库进行存储,一般情况下会用数据库。
- 创建ContentProvider子类,实现核心方法
- 使用UriMatcher对象统一管理不同的Uri
- 在onCreate方法初始化一些数据,注意不要耗时操作,可开启一个子线程执行。另外,只有外界调用了getContentResolver方法,才会触发onCreate的初始化。
- 当数据发生了变化(比如增删改操作)时,需通过ContentResolver的notifyChange方法通知外界访问者ContentProvider中的数据已经改变了。
- 外界通过ContentResolver和Uri对ContentProvider操作
设计数据存储
这里我们使用SQLite进行数据存储,实现SQLiteOpenHelper帮助类的子类
public class BookDbHelper extends SQLiteOpenHelper{
//数据库名称
private static final String DATABASE_NAME="Book.db";
//表名称
public static final String USER_TABLE_NAME="user";
public static final String BOOK_TABLE_NAME="book";
//数据库版本号
private static final int DATABASE_VERSION=1;
public BookDbHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
// 创建两个表格:user表 和book表
//user表是个3列的结构,有id、name、sex三个属性值
db.execSQL("create table if not exists " + USER_TABLE_NAME + "(_id integer primary key autoincrement," + "name TEXT,"+"sex TEXT)");
//book表是个2列结构,有id,book两个属性值
db.execSQL("create table if not exists " + BOOK_TABLE_NAME + "(_id integer primary key autoincrement," + "book TEXT)");
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
}
}
创建ContentProvider子类
public class BookProvider extends ContentProvider{
private static final String TAG = "BookProvider";
//1、ContentProvider唯一标识
public static final String AUTHORITY="com.hzw.progress";
//2、分别为book和user表指定Uri
public static final Uri BOOK_CONTENT_URI=Uri.parse("content://"+AUTHORITY+"/book");
public static final Uri USER_CONTENT_URI=Uri.parse("content://"+AUTHORITY+"/user");
//3、创建UriMatcher对象,用于管理Uri
private static final UriMatcher uriMatcher=new UriMatcher(UriMatcher.NO_MATCH);
private static final int BOOK_URI_CODE=0;
private static final int USER_URI_CODE=1;
//4、初始化UriMatcher元素,将book表和User表的Uri添加到UriMatcher中,可根据code值取对应的表名
static {
uriMatcher.addURI(AUTHORITY,"book",BOOK_URI_CODE);
uriMatcher.addURI(AUTHORITY,"user",USER_URI_CODE);
}
// 5、根据Uri在UriMatcher中匹配得到对应的表名
private String getTableName(Uri uri){
String tableName="";
switch (uriMatcher.match(uri)){
case BOOK_URI_CODE:
tableName=BookDbHelper.BOOK_TABLE_NAME;
break;
case USER_URI_CODE:
tableName=BookDbHelper.USER_TABLE_NAME;
break;
}
return tableName;
}
private BookDbHelper mDbHelper;
private SQLiteDatabase mWritableDatabase;
private Context mContext;
/**
* ContentProvider初始化被调用
* 一般用于创建数据库或者升级等操作,只有在ContentResolver访问的时候,才会触发onCreate方法
* 此方法是主线程执行,不能做耗时操作
* @return true:ContentProvider初始化成功,false则失败
*/
@Override
public boolean onCreate() {
Log.d(TAG, "onCreate: 当前线程:"+Thread.currentThread().getName());
mContext = getContext();
//6、初始化数据库数据,不能在主线程做耗时操作
//创建数据库帮助类,通过BookDbHelper得到SQLite数据库写入实例
mWritableDatabase = new BookDbHelper(mContext).getWritableDatabase();
new Thread(new Runnable() {
@Override
public void run() {
initProviderData();
}
}).start();
return true;
}
private void initProviderData() {
//先删除清空Book、User表的数据
mWritableDatabase.execSQL("delete from "+BookDbHelper.BOOK_TABLE_NAME);
mWritableDatabase.execSQL("delete from "+BookDbHelper.USER_TABLE_NAME);
//接着在数据库中添加数据,分别给book、user表插入数据
mWritableDatabase.execSQL("insert into book values(2,'Android开发艺术探索');");
mWritableDatabase.execSQL("insert into book values(3,'Android进阶之光');");
mWritableDatabase.execSQL("insert into user values(3,'张卫健','男');");
mWritableDatabase.execSQL("insert into user values(4,'微微','女');");
}
/**
* 查询数据 -- 查
* @param uri 根据uri查询具体哪张数据表
* @param projection 确定查询表中哪些列 ,传null则返回所有的列
* @param selection 确定查询哪行,传null这返回所有的行
* @param selectionArgs 与selection类似
* @param sortOrder 用于对查询结果进行排序,传null则使用默认的排序方式,也可以是无序的。
* @return 查询的返回值,是个Cursor对象,在取完数据需进行关闭。否则会内存泄漏
*/
@Nullable
@Override
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) {
Log.d(TAG, "query: 当前线程:"+Thread.currentThread().getName());
String tableName = getTableName(uri);
return mWritableDatabase.query(tableName, projection, selection, selectionArgs,null,null, sortOrder);
}
/**
* 插入一条新数据(添加数据)--增
* @param uri 根据uri插入到具体哪张数据表
* @param values ContentValues底层是key-value键值对结构,使用HashMap实现的
* key:表示列名,value:表示行名,如果value为空,在表中则是空行,无内容
* @return 添加成功后,返回这条新数据的uri。
*/
@Nullable
@Override
public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
Log.d(TAG, "insert: 当前线程:"+Thread.currentThread().getName());
// a、通过Uri插入具体表中
String tableName = getTableName(uri);
mWritableDatabase.insert(tableName, null, values);
//b、数据发生改变,通知外部调用者
mContext.getContentResolver().notifyChange(uri,null);
return uri;
}
/**
* 删除数据 -- 删
* @param uri 根据uri删除哪张表的数据
* @param selection 根据条件删除具体哪行数据
* @param selectionArgs 与selection类似
* @return 返回被删除的行数。
*/
@Override
public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
Log.d(TAG, "delete: 当前线程"+Thread.currentThread().getName());
String tableName = getTableName(uri);
int count = mWritableDatabase.delete(tableName, selection, selectionArgs);
//如果大于0,则删除成功
if (count>0){
mContext.getContentResolver().notifyChange(uri,null);
}
return count;
}
/**
* 更新数据 -- 改
* @param uri 根据Uri修改具体哪张表的数据
* @param values 与insert的ContentValues一样,key是列名,若传入的value为空,则会删除原来的数据置空。
* @param selection 选择符合该条件的行数据进行修改
* @param selectionArgs 与selection类似
* @return 更新的行数
*/
@Override
public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) {
Log.d(TAG, "update: 当前线程:"+Thread.currentThread().getName());
String tableName = getTableName(uri);
int rows = mWritableDatabase.update(tableName, values, selection, selectionArgs);
if (rows>0){
mContext.getContentResolver().notifyChange(uri,null);
}
return 0;
}
/**
* 返回指定内容URL的MIME类型
* @param uri 具体Url
* @return MIME类型 比如图片、视频等等 ,可直接返回null
*/
@Nullable
@Override
public String getType(@NonNull Uri uri) {
return null;
}
}
紧接着需要另外一个进程的Mainfest文件声明注册ContentProvider,并设置permission和authorities属性值。
<!--自定义访问ContentProvider的权限-->
<permission android:name="com.hzw.PROVIDER"/>
<!--在进程中注册该权限-->
<uses-permission android:name="com.hzw.PROVIDER"/>
****
<provider
android:name=".provider.BookProvider"
android:authorities="com.hzw.progress"
android:permission="com.hzw.PROVIDER"
android:exported="true"
android:process=":provider"/>
通过 ContentResolver 和 URI 进行增删改查
//检查是否授权
int checkSelfPermission1 = ActivityCompat.checkSelfPermission(this, com.hzw.progress.Manifest.permission.PROVIDER);
if (checkSelfPermission1!= PackageManager.PERMISSION_GRANTED){
Log.d(TAG, "当前进程没有获取BookProvider的权限,不可进行访问");
return;
}
Uri bookUri = Uri.parse("content://com.hzw.progress/book");
//在book表添加一条数据
ContentValues values = new ContentValues();
values.put("_id","6");
values.put("book","Android高级进阶");
getContentResolver().insert(bookUri,values);
//查询book表所有数据
Cursor bookCursor = getContentResolver().query(bookUri, new String[]{"_id","book"}, null, null, null);
if (bookCursor!=null){
while (bookCursor.moveToNext()){
int bookId = bookCursor.getInt(0);
String bookName = bookCursor.getString(1);
Log.d(TAG, "bookId: "+bookId+",bookName:"+bookName);
}
}
if (bookCursor!=null){
bookCursor.close();
}
//查询user表所有数据
Uri userUri = Uri.parse("content://com.hzw.progress/user");
Cursor userCursor = getContentResolver().query(userUri, new String[]{"_id","name","sex"}, null, null, null);
if (userCursor!=null){
while (userCursor.moveToNext()){
int userId = userCursor.getInt(0);
String userName = userCursor.getString(1);
String sex = userCursor.getString(2);
Log.d(TAG, "userId: "+userId+",userName:"+userName+",sex:"+sex);
}
}
if (userCursor!=null){
userCursor.close();
}
以上运行结果:
到这里ContentProvider内容提供者一些常用知识点基本差不多了,底层的实现同样是Binder机制,ContentProvider对Binder进行了封装。在使用ContentProvider跨进程通信也会存在一些安全性问题,比如SQL语句注入、线程同步等问题。后续还需对一些几点学习
- SQL语句注入
- 线程同步
- CUDR源码
参考
- 第一行代码2版
- 安卓开发艺术探索
- https://blog.csdn.net/harvic880925/article/details/44521461
- http://www.cnblogs.com/linjiqin/archive/2011/05/28/2061396.html
- https://www.jianshu.com/p/7690d93bb1a1
- http://blog.sina.com.cn/s/blog_6cf0d3f301016qfa.html