[《第一行Android代码》读书笔记] 第7章 跨程序共享数据——探究内容提供器

第七章 跨程序共享数据——探究内容提供器

​ 数据持久化技术,包括文件存储、SharedPreferences存储以及数据库存储,都只能在当前应用程序中访问。跨程序数据共享需要用到另一种技术——内容提供器。

7.1 内容提供器简介

​ 内容提供器(Content Provider)主要用于不同的应用程序之间实现数据共享的功能,同时保证被访数据的安全性,是实现跨程序共享数据的标准方式。

7.2 运行时权限

​ Android权限机制作用比较有限,容易出现“店大欺客”现象。因此在Android 6.0引入运行时权限。

7.2.1 Android权限机制详解

​ 为了访问系统的网络状态以及监听开机广播,于是在AndroidManifest.xml文件中添加了两句权限声明:

<use-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<use-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

因为涉及用户设备安全性,因此必须在该文件加入权限声明,否则程序会崩溃。

使用该机制,用户主要在两方面得到了保护:

  • 在低于6.0系统的设备安装程序,在安装界面会给出提醒,让用户知晓程序申请了哪些权限,从而决定是否安装该程序。
  • 用户可以随时在应用程序管理界面查看任意一个程序的权限申请情况,保证应用程序不会滥用权限。

该权限设计思路:用户如果认可你所申请的权限,那就安装,否则就拒绝安装。

运行时权限:用户不需要在安装应用程序时一次性赋予所有申请权限,而是在运行的时候再对某一权限的申请进行授权。这样使得用户可以拒绝某一权限,但仍然可以继续使用该应用。

Android权限分为三类:

  • 普通权限(不会直接威胁到用户的安全和隐私的权限,如设备网络状态和开机自启动等,由系统自动授权)
  • 危险权限(可能会触及用户隐私和对设备的安全性造成影响,如设备联系人信息和地理位置等,由用户手动点击授权)
  • 特殊权限(用的很少)

除了危险权限之外,剩余的都是普通权限。下表列出了Android中的所有危险权限,一共9组24个权限。

权限组名 权限名
CALENDAR READ_CALENDAR
WRITE_CALENDAR
CAMERA CAMERA
CONTACTS READ_CONTACTS
WRITE_CONTACTS
GET_ACCOUNTS
LOCATION ACCESS_FINE_LOCATION -
ACCESS_COARSE_LOCATION -
MICROPHONE RECORD_AUDIO
PHONE READ_PHONE_STATE
CALL_PHONE
READ_CALL_LOG
WRITE_CALL_LOG
ADD_VOICEMAIL
USE_SIP -
PROCESS_OUTGOING_CALLS
SENSOR BODY_SENSORS
SMS - SEND_SMS
RECEIVE_SMS
READ_SMS
RECEIVE_WAP_PUSH
RECEIVE_MMS
STORAGE READ_EXTERNAL_STORAGE
WRITE_EXTERNAL_STORAGE

Android完整权限列表

7.2.2 在程序运行时申请权限

新建项目RuntimePermissionTest,修改activity_main.xml布局文件,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <Button
        android:id="@+id/make_call"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Make Call"/>
</LinearLayout>

接着修改MainActivity中的代码,如下所示:

package com.example.runtimepermissiontest;

import androidx.appcompat.app.AppCompatActivity;

import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Button makeCall = findViewById(R.id.make_call);
        makeCall.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                try{
                    Intent intent = new Intent(Intent.ACTION_CALL);
                    //Intent.ACTION_CALL是一个系统内置的打电话动作
                    intent.setData(Uri.parse("tel:10086"));
                    //指定了协议是tel,号码是10086
                    startActivity(intent);
                }catch (SecurityException e){
                    e.printStackTrace();
                }
            }
        });
    }
}

接下来修改AndroidManifest.xml文件

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.runtimepermissiontest">
 
	<!--声明权限-->
    <uses-permission android:name="android.permission.CALL_PHONE" />
    
    <application
       ...
    </application>

</manifest>

运行程序报错是因为权限被禁止所导致,因为6.0系统及以上系统在使用危险权限时都必须进行运行时权限处理。

修改MainActivity的代码,来修复这个问题,如下所示:

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Button makeCall = findViewById(R.id.make_call);
        makeCall.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                if (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.CALL_PHONE)
                    != PackageManager.PERMISSION_GRANTED) {
                    /*ContextCompat.checkSelfPermission()方法判断用户是否已经给该应用授权,第二个参数是权限名
                    返回值与PackageManager.PERMISSION_GRANTED比较,相等表示授权,否则表示没有授权*/
                    ActivityCompat.requestPermissions(MainActivity.this, new
                                                      String[]{Manifest.permission.CALL_PHONE}, 1);
                    //如果没有授权就调用 ActivityCompat.requestPermissions()方法,第二个参数是权限名,第三个是请求码,唯一值就行
                } 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();
        }
    }

    /*
    *调用完ActivityCompat.requestPermissions()方法之后,系统会弹出一个权限申请的对话框让用户选择,无论结果如何
    *都会回调至onRequestPermissionsResult()方法中。授权结果将会放在grantResults参数中。
    * */
    @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(this, "You denied th permission", Toast.LENGTH_SHORT).show();
                }
                break;
        }
    }
}

7.3 访问其他程序中的数据

内容提供器的使用方法有两种,一种是使用现在的内容提供器来读取和操作相应程序中的数据,另一种是创建自己的内容提供器给我们的程序提供外部访问接口。

7.3.1 ContentResolver的基本用法

首先通过Context中的getContextResolver()方法获取该类的实例,然后调用该类提供的一系列方法用于对数据进行CRUD操作。其中:

  • insert() 方法:用于添加数据
  • update() 方法:用于更新数据
  • delete() 方法:用于删除数据
  • query() 方法:用于查询数据
内容URI

这些方法接收一个Uri参数,这个参数被称为内容URI,用于给内容提供器中的数据建立起唯一的标识符。它由两部分组成:

  • authority:用于区分不同的应用程序,一般用程序包名命名,比如某个程序的包名是com.example.app,那么该程序的authority可以命名为com.example.app.provider
  • path:用于区分同一个应用程序中的不同表,通常添加到authority的后面

此外,在前面还需要加上协议声明。因此,内容URI的最标准格式写法如下:content://com.example.app.provider/table

另外,可以在后面加上一个id,表示访问id为该值的数据,如下所示标准访问id为1的数据:

content://com.example.app.provider/table/1

我们还可以使用通配符的方式来分别匹配这两种格式的内容URI,规则如下:

*:表示匹配任意长度的任意字符。

#:表示匹配任意长度的数字。

所以一个能匹配任意表的内容URI格式可以写成:

content://com.example.app.provider/*

而一个能够匹配table表中任意一行数据的内容URI格式可以写成:

content://com.example.app.provider/table/#

接着,我们再借助UriMatcher类就可以实现匹配内容URI的功能了。

得到内容URI后还需要将它解析成Uri对象才可以作为参数传入。解析方法如下:

Uri uri = Uri.parse("content://com.example.app.provider/table")
查询数据示例

然后,就可以使用这个Uri对象来查询table表中的数据了,代码如下所示:

Cursor cursor = getContextResolver().query(uri,projection,selection,selectionArgs,sortOrder);

其中参数解释如下:

query()方法参数 对应SQL部分 描述
uri from table_name 指定查询某个应用程序下的某一张表
projection select column1,column2 指定查询的列名
selection where column = value 指定where的约束条件
selectionArgs - 为where中的占位符提供具体的值
sortOrder order by column1,column2 指定查询结果的排序方式

查询完成后将返回一个Cursor对象,可以通过移动游标位置来遍历Cursor的每一行,然后再取出每一行的相应列的数据,代码如下所示:

if(cusor != null){
    while(cursor.moveToNext()){
        String column1 = cursor.getString(cursor.getColumnIndex("column1");
        int column1 = cursor.getInt(cursor.getColumnIndex("column1"); 
    }
    cursor.close();                                
}

除了查询数据比较复杂,增删改操作就比较容易了。

添加数据示例
ContentValues values = new ContentValues();
values.put("column1", "text");
values.put("column2", 1);
getContentResolver().insert(uri,values);

更新数据示例
ContentValues values = new ContentValues();
values.put("column1", "text2");
values.put("column2", 2);
getContentResolver().update(uri,values,"column1 = ? and column2 = ?", new String[]{"text","1"});

删除数据示例
getContentResolver().delete(uri,"column2 = ?", new String[]{"1"});

7.3.2 读取系统联系人

首先,在手机电话簿中创建一些联系人。然后创建一个ContactsTest项目。接着修改activity_main.xml中的代码,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <ListView
        android:id="@+id/contacts_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
    </ListView>

</LinearLayout>

接着修改MainActivity中的代码,如下所示:

public class MainActivity extends AppCompatActivity {

    ArrayAdapter<String> adapter;

    List<String> contactsList = new ArrayList<>();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ListView contactsView = findViewById(R.id.contacts_view);
        adapter = new ArrayAdapter<String>(this, android.R.layout.simple_expandable_list_item_1, contactsList);
        contactsView.setAdapter(adapter);
        if (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.READ_CONTACTS)
            != PackageManager.PERMISSION_GRANTED) {
            /*ContextCompat.checkSelfPermission()方法判断用户是否给该应用授权,第二个参数是权限名
                    返回值与PackageManager.PERMISSION_GRANTED比较,相等表示授权,否则表示没有授权*/
            ActivityCompat.requestPermissions(MainActivity.this, new
                                              String[]{Manifest.permission.READ_CONTACTS}, 1);
            //如果没有授权就调用 ActivityCompat.requestPermissions()方法,第二个参数是权限名,第三个是请求码,唯一值就行
        } else {
            readContacts();
        }
    }

    private void readContacts() {
        Cursor cursor = null;
        try {
            //查询联系人数据,ContactsContract.CommonDataKinds.Phone.CONTENT_URI封装了联系人数据表的URI
            getContentResolver().query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, null, null, null);
            if (cursor.moveToNext()) {
                //获取联系人姓名,常量ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME对应联系人姓名
                String displayName = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME));
                //获取联系人手机号码,常量ContactsContract.CommonDataKinds.Phone.NUMBER对应联系人手机号码
                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();
            }
        }
    }

    /*
     *调用完ActivityCompat.requestPermissions()方法之后,系统会弹出一个权限申请的对话框让用户选择,无论结果如何
     *都会回调至onRequestPermissionsResult()方法中。授权结果将会放在grantResults参数中。
     * */
    @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(this, "You denied th permission", Toast.LENGTH_SHORT).show();
                }
                break;
        }
    }
}

别忘了,还需要在AndroidManifest.xml声明读取系统联系人的权限,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.contactstest">
    <!--声明读取系统联系人的权限-->
    <uses-permission android:name="android.permission.READ_CONTACTS"/>
    
    <application
      ...
    </application>
</manifest>

7.4 创建自己的内容提供器

7.4.1 创建内容提供器的步骤

新建一个类去继承ContentProvider的方式来创建一个自己的内容提供器,然后重写该类的6个抽象方法,代码如下所示:

class MyProvider extends ContentProvider{
    /**
     * 通常会在这完成数据库的创建和升级等操作
     * @return 返回true表示初始化成功,否则表示失败
     */
    @Override
    public boolean onCreate() {
        return false;
    }

    @Nullable
    @Override
    public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) {
        return null;
    }

    /**
     * 根据传入的内容URI来返回相应的MIME类型
     * @param uri 内容URI
     * @return 相应的MIME类型
     */
    @Nullable
    @Override
    public String getType(@NonNull Uri uri) {
        return null;
    }

    @Nullable
    @Override
    public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
        return null;
    }

    @Override
    public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
        return 0;
    }

    @Override
    public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) {
        return 0;
    }
}

因为所有的CRUD操作都一定要匹配到相应的内容URI格式才能进行的,而我们当然不可能向UriMatch中添加隐私数据的URI,所以这部分数据根本无法被外部程序访问到,因此保证了隐私数据不会泄露出去。

7.4.2 实现跨程序数据共享

打开上一章DatabaseTest项目,创建一个内容提供器,代码如下所示:

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.example.databasetest.provider";
    
    private static UriMatcher uriMatcher;
    
    private MyDatabaseHelper dbHelper;

    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);
    }

    public DatabaseProvider() {
    }

    @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;
            default:
                break;
        }
        return deletedRows;
    }

    @Override
    public String getType(Uri uri) {
        switch (uriMatcher.match(uri)) {
            case BOOK_DIR:
                return "vnd.android.cursor.dir/vnd.com.example.databasetest.provider.book";
            case BOOK_ITEM:
                return "vnd.android.cursor.dir/vnd.com.example.databasetest.provider.book";
            case CATEGORY_DIR:
                return "vnd.android.cursor.dir/vnd.com.example.databasetest.provider.category";
            case CATEGORY_ITEM:
                return "vnd.android.cursor.dir/vnd.com.example.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;
            default:
                break;
        }
        return uriReturn;
    }

    @Override
    public boolean 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);//将内容URI权限后的部分以“/”进行分割并放入一个字符串列表,第0个位置是路径,第1个位置是id
                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;
            default:
                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);
                db.update("Category", values, "id =?", new String[]{categoryId});
                break;
        }
        return updatedRows;
    }
}


删除并重新安装DatabaseTest程序,接着新建一个新项目ProviderTest。先修改activity_main布局文件,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <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 = findViewById(R.id.add_data);
        addData.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                //添加数据
                Uri uri = Uri.parse("content://com.example.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", 22.85);
                Uri newUri = getContentResolver().insert(uri, values);
                newId = newUri.getPathSegments().get(1);
            }
        });
        Button queryData = findViewById(R.id.query_data);
        queryData.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                //查询数据
                Uri uri = Uri.parse("content://com.example.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"));
                        float prices = cursor.getFloat(cursor.getColumnIndex("prices"));
                        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 prices is " + prices);
                    }
                    cursor.close();
                }
            }
        });
        Button updateData = findViewById(R.id.update_data);
        updateData.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                //更新数据
                Uri uri = Uri.parse("content://com.example.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 = findViewById(R.id.delete_data);
        updateData.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                //删除数据
                Uri uri = Uri.parse("content://com.example.databasetest.provider/book/" + newId);
                getContentResolver().delete(uri, null, null);
            }
        });
    }
}


7.5 Git时间——版本控制进阶

7.6 小结和点评

在本章中,我们一开始了解了Android的权限机制,并且学会了如何在6.0以上的系统使用运行时权限,然后又学习了内容提供器的相关内容,以实现跨程序数据共享的功能。

发布了3 篇原创文章 · 获赞 2 · 访问量 65

猜你喜欢

转载自blog.csdn.net/weixin_41297079/article/details/104397984