本文基于书籍《Android第一行代码》
一、内容提供器简介
内容提供器(Content Provider)主要用于在不同的应用程序之间实现数据共享的功能,而这种数据共享的数据来源于不同App的SQLite。内容提供器的强大在于其能够选择只对哪一部分数据进行共享,保证了隐私。
在学习内容提供器前,首先需要掌握Android6.0版本开始引入的新特性——运行时权限。
二、运行时权限
在低于Android6.0以下的版本中,App要访问一些危险权限如:查看联系人信息、拨打电话号码,只需要在Manifest文件中注册一下就可以了,但这样就导致很多App注册权限泛滥,因此Android6.0开始引入了运行时权限。
运行时权限只针对危险权限,即当App要申请危险权限时需要向用户发起申请,类似于IOS中的权限申请功能;而普通权限则由系统自动帮我们申请。Android中的危险权限共有9组24个权限。
除去上面的危险权限外,剩下的基本上都是普通权限。如果是属于这张表中的权限,那么就需要进行运行时权限处理,如果不在这张表中,那么只需要在AndroidManifest文件中添加一下权限声明即可。
但需要注意的是,当我们同意授权一个危险权限时,属于这个危险权限所在组的其他的权限也会自动授权。
那么如何申请运行时权限呢?
首先创建一个新的RuntimePermissionTest项目,修改activity_main.xml文件
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context="com.studio.runtimepermissiontest.MainActivity">
<Button
android:id="@+id/make_call"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Make Call"/>
</LinearLayout>
为布局添加一个按钮,点击后拨打电话,拨打电话的权限时CALL_PHONE,属于危险权限,下面修改MainActivity中的代码
public class MainActivity extends AppCompatActivity
{
@Override
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Button makeCall= (Button) findViewById(R.id.make_call);
makeCall.setOnClickListener(new View.OnClickListener()
{
@Override
public void onClick(View v)
{
//判断用户是否已经授权,未授权则向用户申请授权,已授权则直接进行呼叫操作
if(ContextCompat.checkSelfPermission(MainActivity.this,android.Manifest.permission.CALL_PHONE)
!= PackageManager.PERMISSION_GRANTED)
{
//注意第二个参数没有双引号
ActivityCompat.requestPermissions(MainActivity.this, new String[]{android.Manifest.permission.CALL_PHONE},1);
}
else
{
call();
}
}
});
}
//封装拨打电话的操作
private void call()
{
try
{
Intent intent=new Intent(Intent.ACTION_CALL);
intent.setData(Uri.parse("tel:10086"));
startActivity(intent);
}
catch (SecurityException e)
{
e.printStackTrace();
}
}
//用户选择同意或者拒绝权限申请后回调的方法
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults)
{
switch (requestCode)
{
//如果同意授权则进行呼叫操作,不同意授权则提醒用户
case 1:
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED)
{
call();
}
else
{
Toast.makeText(MainActivity.this,"You denied the permission",Toast.LENGTH_SHORT).show();
}
break;
}
}
}
在按钮点击事件中,我们构建了一个隐式Intent,Intent的action指定为Intent.ACTION_CALL,这是系统内置打电话的动作,然后在data部分指定协议为tel,号码为10086。
接下来在Manifest中注册这个权限
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.studio.runtimepermissiontest">
<uses-permission android:name="android.permission.CALL_PHONE"/>
....
....
....
这样我们就完成了运行时权限的完整流程,下面具体分析一下。运行时权限说白了就是在程序运行的过程中由用户授权App去执行某些危险操作,App无法擅自做主。因此,第一步就是要先判断用户是否已经给过授权,这里借助的是ContextCompat.checkSelfPermission()方法。这个方法接收两个参数,第一个是Context,第二个是具体的权限名,比如打电话的权限名这就是Manifest.permission.CALL_PHONE里需要注意最好在权限名前加上android,因为Manifest.permission类下宏定义了很多权限名,但是AndroidStudio在很多情况下不会导入android包下的Manifest而是会导入这个app的Manifest,具体答案在StackOverflow上有详细的解答,网址为http://stackoverflow.com/questions/34139048/cannot-resolve-manifest-permission-access-fine-location
之后我们用这个方法的返回值和PackageManager.PERMISSION_GRANTED作比较,相等就说明用户已经授权,不等则没有授权。
如果已经授权过,那么直接执行拨打电话的操作即可,这里我们把拨打电话的逻辑封装到方法call()中。
如果没有授权过,则需要调用ActivityCompat.requestPermission()方法来向用户申请授权,这个方法接收三个参数,第一个参数是Activity的实例,第二个参数是String数组,我们把要申请的权限名放在数据中即可,第三个参数是请求码(requestCode),只要是唯一值即可,这里传入了1。
执行完requestPermission()方法后系统会弹出一个权限申请的对话框,然后用户可以选择同意或者拒绝,不论是哪种结果,最后都会回调onRequestPermissionResult()方法,授权的结果会封装在grantResults参数中。这里我们只需要判断一下最后的授权结果,如果用户同意就调用call()方法拨打电话,如果不同意就弹出一条失败提示即可。
运行App,点击Make Call按钮,之后点击同意,就会自动拨打10086了。
三、访问其他程序的数据
1、ContentResolver的用法
要想访问内容提供器中的数据就必要要借助ContentResolver类,我们可以通过Context类下的getContentResolver()方法获取到该类的实例对象。ContentResolver类中提供了一系列方法用于对数据的CRUD操作,如insert()、delete()、update()和query(),就方法名而言是和SQLiteDatabase是一样的。
但是不同于SQLiteDatabase,ContentResolver中的CRUD方法的参数接收的不是数据表名,而是一个Uri对象,这个参数被称为内容URI。内容URI给内容提供器中的数据建立了唯一标识符,它主要由两部分组成:authority和path。authority是用于对不同的App做区分的,一般采用程序包名的方式来命名,例如某个App的包名是com.studio.app,那么这个App对应的authority就是com.studio.app.provider。path则是用于对同一个App中的不同的数据表做区分的,例如某个程序的数据库里有两张表table1和table2,这时就可以将path分别命名为/table1和/table2,然后把authority和path进行组合,内容URI就变成了com.studio.app.provider/table1和com.studio.app.provider/table2,但是一个完整标准的URI还需要一个协议声明content://,因此标准的URI为:content://com.studio.app.provider/table1和content://com.studio.app.provider/table2。
因此内容URI可以清楚地表达出我们要访问哪个App里的哪张表的数据,如果只有表名,那么我们将无法知道要访问哪个App。
在得到了内容URI字符串后,我们还需要将其解析为Uri对象才能作为参数传入,解析的方法就是Uri.parse()
Uri uri=Uri.parse("content://com.studio.app.provider/table1")
只需要调用Uri.parse()方法就可以将URI字符串解析为Uri对象。
现在我们可以使用这个Uri对象来查询table1表中的数据了,代码如下所示
Cursor cursor = getContentResolver().query(uri , projection , selection , selectionArgs , sortOrder);
这些参数和SQLiteDatabase中的query()方法里的参数很像,但要简单一些,下表对这些参数做了详细解释。
ContentValues values = new ContentValues();
values.put("column1","text");
values.put("column2",1);
getContentResolver().insert(uri,values);
下面是向表table1中更新数据的代码。
ContentValues values=new ContentValues();
values.put("column1","");
getContentResolver().update(uri,values,"column1 = ? and column2 = ?",new String[]{"text1" , "1"});
注意上述代码使用了selection和selectionArgs参数来对想要更新的数据进行约束,以防止所有的行都会受影响。
getContentResolver().delete(uri,"column2 = ?",new String[]{"1"});
到这里我们就把ContentResolver类下的CRUD方法学完了,下面我们实战学习如何读取系统App电话簿里的联系人信息数据。
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.studio.contactstest.MainActivity">
<ListView
android:id="@+id/contacts_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
</ListView>
</LinearLayout>
接着修改MainActivity中的代码。
public class MainActivity extends AppCompatActivity
{
//全局声明ListView的适配器和数据源
ArrayAdapter<String> adapter;
List<String> contactsList=new ArrayList<>();
@Override
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
ListView contactsView= (ListView) findViewById(R.id.contacts_view);
//实例化适配器
adapter = new ArrayAdapter<String>(this,android.R.layout.simple_list_item_1,contactsList);
contactsView.setAdapter(adapter);
//运行时权限:向用户申请访问联系人信息的权限。若用户已经同意过,则读取联系人信息;若不曾同意过,则向用户申请权限
if(ContextCompat.checkSelfPermission(MainActivity.this,android.Manifest.permission.READ_CONTACTS)!=
PackageManager.PERMISSION_GRANTED)
{
ActivityCompat.requestPermissions(MainActivity.this,new String[]{android.Manifest.permission.READ_CONTACTS},1);
}
else
{
readContacts();
}
}
//读取联系人信息,适配到ListView上
private void readContacts()
{
Cursor cursor=null;
try
{
//查询联系人数据
cursor=getContentResolver().query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI,null,null,null,null);
while(cursor.moveToNext())
{
//获取联系人姓名
String displayName = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME));
//获取联系人手机号
String number = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER));
contactsList.add(displayName + "\n" + number);
}
//更新适配器内容,刷新ListView
adapter.notifyDataSetChanged();
}
catch(Exception e)
{
e.printStackTrace();
}
finally
{
if (cursor!=null)
{
cursor.close();
}
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults)
{
switch (requestCode)
{
case 1:
{
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED)
{
readContacts();
}
else
{
Toast.makeText(MainActivity.this,"You denied the permission",Toast.LENGTH_SHORT).show();
}
}
}
}
}
在onCreate()方法中,我们获取ListView控件的实例,设置好适配器,然后由于READ_CONTACTS属于危险权限,因此需要进行运行时权限处理,这里readContacts()方法是封装好的读取联系人数据并且部署在ListView上的操作逻辑。
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.studio.contactstest">
<uses-permission android:name="android.permission.READ_CONTACTS"></uses-permission>
...
大功告成,运行App,授予权限后效果如图。
public class MyProvider extends ContentProvider
{
@Override
public boolean onCreate()
{
return false;
}
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder)
{
return null;
}
@Nullable
@Override
public String getType(Uri uri)
{
return null;
}
@Nullable
@Override
public Uri insert(Uri uri, ContentValues values)
{
return null;
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs)
{
return 0;
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs)
{
return 0;
}
}
下面来简单介绍一下这六个重写的方法:
- * :表示匹配任意长度的任意字符
- # :表示匹配任意长度的数字
public class MyProvider extends ContentProvider
{
//声明URI对应的自定义代码
public static final int TABLE1_DIR=0;
public static final int TABLE1_ITEM=1;
public static final int TABLE2_DIR=2;
public static final int TABLE2_ITEM=3;
//声明全局UriMatcher
private static UriMatcher uriMatcher;
static
{
uriMatcher=new UriMatcher(UriMatcher.NO_MATCH);
uriMatcher.addURI("com.studio.app.provider","table1",TABLE1_DIR);
uriMatcher.addURI("com.studio.app.provider","table1/#",TABLE1_ITEM);
uriMatcher.addURI("com.studio.app.provider","table2",TABLE2_DIR);
uriMatcher.addURI("com.studio.app.provider","table2/#",TABLE2_ITEM);
}
@Override
public boolean onCreate()
{
return false;
}
@Nullable
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder)
{
Cursor cursor=null;
switch (uriMatcher.match(uri))
{
case TABLE1_DIR:
{
//查询table1表中的所有数据
}
break;
case TABLE1_ITEM:
{
//查询table1表中的单条数据
}
break;
case TABLE2_DIR:
{
//查询table2表中的所有数据
}
break;
case TABLE2_ITEM:
{
//查询table2表中的单条数据
}
break;
}
return cursor;
}
@Nullable
@Override
public String getType(Uri uri)
{
switch (uriMatcher.match(uri))
{
case TABLE1_DIR:
{
return "vnd.android.cursor.dir/vnd.com.studio.app.provider.table1";
}
case TABLE1_ITEM:
{
return "vnd.android.cursor.item/vnd.com.studio.app.provider.table1";
}
case TABLE2_DIR:
{
return "vnd.android.cursor.dir/vnd.com.studio.app.provider.table2";
}
case TABLE2_ITEM:
{
return "vnd.android.cursor.item/vnd.com.studio.app.provider.table2";
}
}
return null;
}
@Nullable
@Override
public Uri insert(Uri uri, ContentValues values)
{
return null;
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs)
{
return 0;
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs)
{
return 0;
}
}
首先我们新增了四个整型常量用来表示表和表中的某条数据,这就是自定义代码。接着在静态代码块中创建UriMatcher实例,利用addUri()方法将期望匹配的内容URI格式传入。然后当调用方利用ContentResolver类下的query()方法试图访问另外app中的数据时,MyProvider类下的query()方法就会调用,利用UriMatcher类下的match()方法将传过来的Uri对象进行匹配,如果发现UriMatcher中的某个内容URI格式成功匹配到了传入的Uri对象,就会返回对应的自定义代码,这样我们就知道调用方期望访问的数据了。
- 必须以vnd开头
- 如果内容URI以路径结尾,则后接android.cursor.dir/,如果内容URI以id结尾,则后接android.cursor.item/
- 最后接上vnd.<authority>.<path>
public class DatabaseProvider extends ContentProvider
{
public static final int BOOK_DIR=0;
public static final int BOOK_ITEM=1;
public static final int CATEGORY_DIR=2;
public static final int CATEGORY_ITEM=3;
public static final String AUTHORITY="com.studio.databasetest.provider";
private MyDatabaseHelper dbHelper;
private static UriMatcher uriMatcher;
static
{
uriMatcher=new UriMatcher(UriMatcher.NO_MATCH);
uriMatcher.addURI(AUTHORITY,"book",BOOK_DIR);
uriMatcher.addURI(AUTHORITY,"book/#",BOOK_ITEM);
uriMatcher.addURI(AUTHORITY,"category",CATEGORY_DIR);
uriMatcher.addURI(AUTHORITY,"category/#",CATEGORY_ITEM);
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs)
{
SQLiteDatabase db=dbHelper.getWritableDatabase();
int deletedRows=0;
switch (uriMatcher.match(uri))
{
case BOOK_DIR:
{
deletedRows=db.delete("Book",selection,selectionArgs);
}
break;
case BOOK_ITEM:
{
String bookId=uri.getPathSegments().get(1);
deletedRows=db.delete("Book","id = ?",new String[]{bookId});
}
break;
case CATEGORY_DIR:
{
deletedRows=db.delete("Category",selection,selectionArgs);
}
break;
case CATEGORY_ITEM:
{
String categoryId=uri.getPathSegments().get(1);
deletedRows=db.delete("Category","id = ?",new String[]{categoryId});
}
break;
}
return deletedRows;
}
@Override
public String getType(Uri uri)
{
switch (uriMatcher.match(uri))
{
case BOOK_DIR:
{
return "vnd.android.cursor.dir/vnd.com.studio.databasetest.provider.book";
}
case BOOK_ITEM:
{
return "vnd.android.cursor.item/vnd.com.studio.databasetest.provider.book";
}
case CATEGORY_DIR:
{
return "vnd.android.cursor.dir/vnd.com.studio.databasetest.provider.category";
}
case CATEGORY_ITEM:
{
return "vnd.android.cursor.item/vnd.com.studio.databasetest.provider.category";
}
}
return null;
}
@Override
public Uri insert(Uri uri, ContentValues values)
{
SQLiteDatabase db=dbHelper.getWritableDatabase();
Uri uriReturn=null;
switch (uriMatcher.match(uri))
{
case BOOK_DIR:
case BOOK_ITEM:
{
long newBookId=db.insert("Book",null,values);
uriReturn=Uri.parse("content://" + AUTHORITY + "/book/" + newBookId);
}
break;
case CATEGORY_DIR:
case CATEGORY_ITEM:
{
long newCategoryId=db.insert("Category",null,values);
uriReturn=Uri.parse("content://" + AUTHORITY + "/category/" + newCategoryId);
}
break;
}
return uriReturn;
}
@Override
public boolean onCreate()
{
//当ContentResolver尝试访问内容提供器时执行onCreate()方法初始化内容提供器
dbHelper=new MyDatabaseHelper(getContext(),"BookStore.db",null,2);
return true;
}
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder)
{
SQLiteDatabase db=dbHelper.getReadableDatabase();
Cursor cursor=null;
switch (uriMatcher.match(uri))
{
case BOOK_DIR:
{
cursor=db.query("Book",projection,selection,selectionArgs,null,null,sortOrder);
}
break;
case BOOK_ITEM:
{
String bookId=uri.getPathSegments().get(1);
cursor=db.query("Book",projection,"id = ?",new String[]{bookId},null,null,sortOrder);
}
break;
case CATEGORY_DIR:
{
cursor=db.query("Category",projection,selection,selectionArgs,null,null,sortOrder);
}
break;
case CATEGORY_ITEM:
{
String categoryId=uri.getPathSegments().get(1);
cursor=db.query("Category",projection,"id = ?",new String[]{categoryId},null,null,sortOrder);
}
break;
}
return cursor;
}
@Override
public int update(Uri uri, ContentValues values, String selection,
String[] selectionArgs)
{
SQLiteDatabase db=dbHelper.getWritableDatabase();
int updatedRows=0;
switch (uriMatcher.match(uri))
{
case BOOK_DIR:
{
updatedRows=db.update("Book",values,selection,selectionArgs);
}
break;
case BOOK_ITEM:
{
String bookId=uri.getPathSegments().get(1);
updatedRows=db.update("Book",values,"id = ?",new String[]{bookId});
}
break;
case CATEGORY_DIR:
{
updatedRows=db.update("Category",values,selection,selectionArgs);
}
break;
case CATEGORY_ITEM:
{
String categoryId=uri.getPathSegments().get(1);
updatedRows=db.update("Category",values,"id = ?",new String[]{categoryId});
}
break;
}
return updatedRows;
}
}
虽然代码比较长但是逻辑比较简单,首先在代码一开始我们就声明了自定义代码分别表示访问Book表中的所有数据、单条数据、Category表中的所有数据、单条数据,然后在静态代码块中对UriMatcher进行了初始化操作,向其中添加了几种期望匹配的URI格式。
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.studio.databasetest">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<provider
android:name=".DatabaseProvider"
android:authorities="com.studio.databasetest.provider"
android:enabled="true"
android:exported="true">
</provider>
</application>
</manifest>
最后我们再新建一个项目ProviderTest来测试一下是否能够跨程序访问这个App的数据。
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context="com.studio.providertest.MainActivity">
<Button
android:id="@+id/add_data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Add To Book"/>
<Button
android:id="@+id/query_data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Query From Book"/>
<Button
android:id="@+id/update_data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Update Book"/>
<Button
android:id="@+id/delete_data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Delete From Book"/>
</LinearLayout>
然后修改MainActivity的代码
public class MainActivity extends AppCompatActivity
{
private String newId;
@Override
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Button addData= (Button) findViewById(R.id.add_data);
addData.setOnClickListener(new View.OnClickListener()
{
@Override
public void onClick(View v)
{
//向另一个软件DatabaseTest中添加数据
Uri uri=Uri.parse("content://com.studio.databasetest.provider/book");
ContentValues values=new ContentValues();
values.put("name","A Clash Of Kings");
values.put("author","George Martin");
values.put("pages",1040);
values.put("price",55.55);
Uri newUri=getContentResolver().insert(uri,values);
newId=newUri.getPathSegments().get(1);
}
});
Button queryData= (Button) findViewById(R.id.query_data);
queryData.setOnClickListener(new View.OnClickListener()
{
//从另一个软件DatabaseTest中查询数据
@Override
public void onClick(View v)
{
Uri uri=Uri.parse("content://com.studio.databasetest.provider/book");
Cursor cursor=getContentResolver().query(uri,null,null,null,null);
if(cursor!=null)
{
while (cursor.moveToNext())
{
String name=cursor.getString(cursor.getColumnIndex("name"));
String author=cursor.getString(cursor.getColumnIndex("author"));
int pages=cursor.getInt(cursor.getColumnIndex("pages"));
double price=cursor.getDouble(cursor.getColumnIndex("price"));
Log.d("MainActivity","book name is "+name);
Log.d("MainActivity","book author is "+author);
Log.d("MainActivity","book pages is "+pages);
Log.d("MainActivity","book price is "+price);
}
}
//最后不要忘了关闭cursor
cursor.close();
}
});
Button updateData = (Button) findViewById(R.id.update_data);
updateData.setOnClickListener(new View.OnClickListener()
{
@Override
public void onClick(View v)
{
Uri uri=Uri.parse("content://com.studio.databasetest.provider/book/"+newId);
ContentValues values=new ContentValues();
values.put("name","A Storm of swords");
values.put("pages",1216);
values.put("price",24.05);
getContentResolver().update(uri,values,null,null);
}
});
Button deleteData = (Button) findViewById(R.id.delete_data);
deleteData.setOnClickListener(new View.OnClickListener()
{
@Override
public void onClick(View v)
{
Uri uri=Uri.parse("content://com.studio.databasetest.provider/book/"+newId);
getContentResolver().delete(uri,null,null);
}
});
}
}
我们分别在四个按钮的点击事件中处理了CRUD的逻辑,注意ContentResolver类下的insert()方法会返回一个Uri对象,这个对象中包含了新添加的数据的id,我们通过getPathSegments()方法将这个id取出,在之后的代码中有用到这个id,这就交给大家自己去琢磨了,不再详细解读,整体并不难。