android 之 Intent

Intent

Intent 用于解决 Android 应用的各项组件之间的通讯,例如:启动组件,传递数据。分为显式 Intent 和隐式 Intent

显式Intent

Intent 有多个构造函数的重载,最常用的显式 Intent 是

//第一个参数Context要求提供一个启动Activity的上下文;
//第二个参数Class用于指定想要启动的目标Activity
public Intent(Context packageContext, Class cls) {
    mComponent = new ComponentName(packageContext, cls);
}

布局文件 activity_main.xml 如下,其中 android:textAllCaps="false" 是控制按钮中的因为字母不要自动转为大写字母




    

在点击按钮时启动自己应用中的另一个 Activity :

button.setOnClickListener {
    val intent = Intent(this, SecondActivity::class.java)
    startActivity(intent)
}

效果

image-20211129174431249.png

image-20211129174621366.png

隐式Intent

隐式 Intent 不会明确指出想要启动哪一个Activity,而是指定了一系列更为抽象的action和category等信息,然后交由系统去分析这个Intent,并帮我们找出合适的Activity去启动

通过在 AndroidManifest.xml 文件中的 标签下配置 的内容,可以指定相应 Activity 能够响应的 action 和 category ,如下


    
        
        
        
        
        
        
        
    

与之匹配的 Intent :

val intent = Intent("com.example.androidstudy.ACTION_START_SECOND_ACTIVITY")
intent.setDataAndType(Uri.parse("http://www.baidu.com"), "image/*")
intent.addCategory("com.example.androidstudy.category.TEST_CATEGORY")
startActivity(intent)

注意

  1. 需要同时匹配过滤列表中的 action、category、data 信息,否则匹配失败
  2. 一个 Activity 中可以有多个 intent-filter ,一个 Intent 只要能匹配任何一组 intent-filter 即可

action的匹配规则

要求Intent中的action存在且必须和过滤规则中的其中一个action相同

category的匹配规则

要求Intent中如果含有category,那么所有的category都必须和过滤规则中的其中一个category相同

注意

系统在调用startActivity或者startActivityForResult的时候会默认为Intent加上“android.intent. category.DEFAULT”这个category,因此被调用的Activity必须有

data的匹配规则

如果过滤规则中定义了data,那么Intent中必须也要定义可匹配的data

data的语法
 android:scheme=""
 android:host=""
 android:port=""
 android:path=""
 android:pathPattern=""
 android:pathPrefix=""
 android:mimeType="" />

data由两部分组成,mimeType和URI

mimeType

mimeType指媒体类型,比如image/jpeg、audio/mpeg4-generic和video/*等

URI
://:/[||]

scheme:协议。例如:http、file、content等

host:主机名。例如:www.baidu.com

port:端口。例如:80

path、pathPrefix 和 pathPattern:这三个参数都表示路径信息,可以三者选其一

  1. path表示完整的路径信息

  2. 可以包含通配符 * ,表示0个或多个任意字符

  3. pathPrefix表示路径的前缀信息
    URI 的例子:

content://com.example.androidstudy:200/floder/subfloder/etc
http://www.baidu.com:80/search/info

注意

如果要为Intent指定完整的data,必须要调用setDataAndType方法,不能先调用setData再调用setType,因为这两个方法彼此会清除对方的值

隐式Intent启动Activity前应判断

当通过隐式方式启动一个 Activity 之前,应该判断下是否有 Activity 能够匹配我们的隐式 Intent,有两种方法,找不到匹配的Activity就会返回null

  1. PackageManager 的 resolveActivity 方法
  2. Intent 的 resolveActivity 方法

PackageManager还提供了queryIntentActivities方法:

public abstract List queryIntentActivities(@NonNull Intent intent, @ResolveInfoFlags int flags);

它不是返回最佳匹配的Activity信息而是返回所有成功匹配的Activity信息,第二个参数要使用MATCH_DEFAULT_ONLY这个标记位,含义是仅匹配那些在intent-filter中声明了 这个category的Activity

使用Android系统内置的动作

调用系统的浏览器

val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse("http://www.baidu.com")
startActivity(intent)

调拨打电话界面

val intent = Intent(Intent.ACTION_DIAL)
intent.data = Uri.parse("tel:10086")
startActivity(intent)

地图

val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse("geo:38.899533,-77.036476")
startActivity(intent)

直接拨打电话

在 AndroidManifest.xml 中还需要声明权限


代码

if (ContextCompat.checkSelfPermission(
        applicationContext,
        Manifest.permission.CALL_PHONE
    ) == PackageManager.PERMISSION_DENIED
) {
    requestPermissions(arrayOf(Manifest.permission.CALL_PHONE), 1)
} else {
    val intent = Intent(Intent.ACTION_CALL)
    intent.data = Uri.parse("tel:10086")
    startActivity(intent)
}

卸载应用,会弹出系统卸载的确认框

在 AndroidManifest.xml 中还需要声明权限

 

但代码里不需要动态申请权限

val intent = Intent(Intent.ACTION_DELETE)
intent.data = Uri.parse("package:com.example.kotlin")
startActivity(intent)

安装应用

Android 7.0 以前
val intent = Intent(Intent.ACTION_VIEW)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.setDataAndType(
    Uri.fromFile(File(cacheDir, "app-debug.apk")),
    "application/vnd.android.package-archive"
)
startActivity(intent)
Android 7.0 开始

分享私有文件的推荐方法是使用 FileProvider

定义一个FileProvider

    

android:name 是FileProvider组件的完整类名
android:authorities 是域名,为了保证唯一性,通常是你的应用包名+fileprovider
android:exported 设置false,不需要暴露它
android:grantUriPermissions 设置true,表示允许你可以对文件授予临时权限

添加可用权限的文件目录

为了将实际的文件路径(file://)映射成content URI(content://),需要一个配置文件xml来提前定义文件存放的目录路径path与Content URI的对应关系。文件放置在res/xml/下



    
    
    
    
    
    
    

上面的整个标签的意思是将私有路径 Context.getFilesDir() 目录映射成 content://android:authorities/,其中 android:authorities 代表前面在定义的 provider 的属性值

标签 对应方法 路径示例
getFilesDir() /data/data/com.example.androidstudy/files/
getCacheDir() /data/data/com.example.androidstudy/cache/
Environment.getExternalStorageDirectory() /storage/emulated/0/
getExternalFilesDir() /storage/emulated/0/Android/data/com.chen.gradle/files/
getExternalCacheDir() /storage/emulated/0/Android/data/com.chen.gradle/cache/
表示根目录,“/”
授予临时的读写权限

在 AndroidManifest.xml 配置 provider 标签的时候,属性 android:grantUriPermissions="true" 表示允许它授予 Uri 临时的权限,当我们生成出一个 content:// 的 Uri 对象之后,还需要对这个 Uri 接收的 App 赋予对应的权限才可以使用,权限是定义于 Intent 中的常量,如下:

public static final int FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
public static final int FLAG_GRANT_WRITE_URI_PERMISSION = 0x00000002;

有两种为其他应用授予 Uri 对象的访问权限的方式:

  1. Context.grantUriPermission(String toPackage, Uri uri, int modeFlags)

    此方法从授权开始,一直到设备重启或者手动调用 Context.revokeUriPermission() 方法,才会收回对此 Uri 的授权

    toPackage:授予权限的 App 的包名

    uri:授予权限的 content:// 的 Uri

    modeFlags:定义在 Intent 中的读写权限

  1. Intent.addFlags(@Flags int flags)

    flags:定义于 Intent 中的常量,例如:FLAG_GRANT_READ_URI_PERMISSION

    此方法从授权开始,一直到应用完全退出

Android 7 安装应用代码
/**
 * Android 7 开始, apk 的安装
 * @param context Context
 * @param apkFilePath String
 */
fun installApkN(context: Context, apkFilePath: String) {
    val file = File(apkFilePath)
    // 使用FileProvider.getUriForFile()方法将file:// 转为 content://
    // authority 参数需要与前面在 AndroidManifest.xml 中 配置的 android:authorities保持一致
    val apkUri = FileProvider.getUriForFile(context, "com.example.androidstudy.fileProvider", file)
    val intent = Intent(Intent.ACTION_VIEW)
    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
    //赋予临时权限给Uri
    intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
    intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
    context.startActivity(intent)
}
Android 8.0 开始

Android 8.0 开始,安装应用需要打开未知来源权限,需要去设置中为需要安装应用的APP开启权限

在 AndroidManifest.xml 中还需要声明权限


Android 8 安装应用代码
const val UNKNOWN_APP_SOURCES_REQUEST_CODE = 0X0008

/**
 * Android 8 开始, apk 的安装
 * @param context Context
 * @param apkFilePath String
 */
fun installApkO(context: Context, apkFilePath: String) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val isGranted = context.packageManager.canRequestPackageInstalls()
        if (!isGranted) {
            AlertDialog.Builder(context)
                .setCancelable(false)
                .setTitle("安装应用需要打开未知来源权限,请去设置中开启权限")
                .setPositiveButton("确定") { dialog, witch ->
                    val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
                    (context as Activity).startActivityForResult(
                        intent,
                        UNKNOWN_APP_SOURCES_REQUEST_CODE
                    )
                }
                .show()
        } else {
            installApkN(context, apkFilePath)
        }
    }
}

......

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
        && requestCode == UNKNOWN_APP_SOURCES_REQUEST_CODE
        && packageManager.canRequestPackageInstalls()
       ) {
        val apkPath = cacheDir.canonicalPath + "/app-debug.apk"
        installApk(this, apkPath)
    }
}
问题一

先在/data/data/com.example.androidstudy/cache目录下放入app-debug.apk,卸载原来的应用,重新运行在模拟器上

There was a problem while parsing the package

截图:


image-20211130194918016.png

这是因为卸载原来的应用会删除目录 /data/data/com.example.androidstudy/ 下的所有东西,apk文件自然也被删掉了,因此在点击安装前,需要再次确认apk是否在指定目录下

问题二

INSTSLL_PARSE_FAILED_NO_CERTIFICATES:测试的 apk 没有签名导致的,此错误在拖拽 apk 到低版本(我使用的是Nexus 4 API 22)模拟器上才会显示错误信息,如果是高版本(Pixel API 29)直接拖拽进模拟器,可以直接安装,但使用代码安装时,只显示 “App not installed” ,没有其他信息反馈

因此要注意,测试的 apk 一定要使用签名过的,可以使用压缩软件打开 apk ,如果 META-INF 目录下存在 MANIFEST.MF 文件,就是签过名的,否则没有签名

直接使用启动按钮在目录 app\build\outputs\apk\debug 下生成的apk是未签名的,如下:


image-20211204113656657.png

image-20211204114347009.png

使用 Build -> Build Bundle(s)/APK(s) -> Build APK(s) 是签过名的


image-20211204113815184.png

image-20211204114147050.png
总结使用 Intent 安装

要注意在 AndroidManifest.xml 中添加权限


下面是完整的使用 Intent 安装 apk 代码

class MainActivity : AppCompatActivity() {

    companion object {
        private const val TAG = "MainActivity"
        const val UNKNOWN_APP_SOURCES_REQUEST_CODE = 0X0008
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        button.setOnClickListener {
            val apkPath = cacheDir.canonicalPath + "/app-debug.apk"
            Log.d(TAG, "onCreate: apkPath=$apkPath")
            installApk(this, apkPath)
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
            && requestCode == UNKNOWN_APP_SOURCES_REQUEST_CODE
            && packageManager.canRequestPackageInstalls()
        ) {
            val apkPath = cacheDir.canonicalPath + "/app-debug.apk"
            installApk(this, apkPath)
        }
    }
}

/**
 * apk 的安装
 * @param context Context
 * @param apkFilePath String
 */
fun installApk(context: Context, apkFilePath: String) {
    when {
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> {
            installApkO(context, apkFilePath)
        }
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> {
            installApkN(context, apkFilePath)
        }
        else -> {
            installApkBeforeN(context, apkFilePath)
        }
    }
}

/**
 * Android 7 之前, apk 的安装
 * @param context Context
 * @param apkFilePath String
 */
fun installApkBeforeN(context: Context, apkFilePath: String) {
    val intent = Intent(Intent.ACTION_VIEW)
    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
    intent.setDataAndType(
        Uri.fromFile(File(apkFilePath)),
        "application/vnd.android.package-archive"
    )
    context.startActivity(intent)
}

/**
 * Android 7 开始, apk 的安装
 * @param context Context
 * @param apkFilePath String
 */
fun installApkN(context: Context, apkFilePath: String) {
    val file = File(apkFilePath)
    // 使用FileProvider.getUriForFile()方法将file:// 转为 content://
    // authority 参数需要与前面在 AndroidManifest.xml 中 配置的 android:authorities保持一致
    val apkUri = FileProvider.getUriForFile(
        context,
        "com.example.androidstudy.fileProvider",
        file
    )
    Log.d("TAG", "installApkN: apkUri=$apkUri")
    val intent = Intent(Intent.ACTION_VIEW)
    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
    //赋予临时权限给Uri
    intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
    intent.setDataAndType(
        apkUri,
        "application/vnd.android.package-archive"
    )
    context.startActivity(intent)
}

/**
 * Android 8 开始, apk 的安装
 * @param context Context
 * @param apkFilePath String
 */
fun installApkO(context: Context, apkFilePath: String) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val isGranted = context.packageManager.canRequestPackageInstalls()
        if (!isGranted) {
            AlertDialog.Builder(context)
                .setCancelable(false)
                .setTitle("安装应用需要打开未知来源权限,请去设置中开启权限")
                .setPositiveButton("确定") { dialog, witch ->
                    val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
                    (context as Activity).startActivityForResult(
                        intent,
                        UNKNOWN_APP_SOURCES_REQUEST_CODE
                    )
                }
                .show()
        } else {
            installApkN(context, apkFilePath)
        }
    }
}
静默安装
静默安装 aosp 源码

在源码 frameworks/base/core/java/android/content/pm/PackageInstaller.java 文件里,类注释中有以下两行:

The ApiDemos project contains examples of using this API:
ApiDemos/src/com/example/android/apis/content/InstallApk*.java.

这告诉我们安装应用的 API 使用方法在 ApiDemos/src/com/example/android/apis/content/InstallApk*.java 中,所在目录全路径是aosp源码的:aosp/development/samples/ApiDemos/src/com/example/android/apis/content,也就是下图中的两个文件


image-20211204133519576.png

InstallApkSessionApi.java 里是静默安装

InstallApk.java 里是普通安装,也就是前面的调用 intent 进行安装

静默安装注意点
  1. 静默安装需要进行系统签名
  2. 需要在 AndroidManifest.xml 需要添加权限 REQUEST_INSTALL_PACKAGES
  3. 设置启动模式 launchMode 为 singleTask

在 AndroidManifest.xml 需要添加权限和设置启动模式,如下:

权限:

    

启动模式 launchMode:


    
        
        
    

问题一

同样,测试的 apk 不能直接使用启动按钮在目录 app\build\outputs\apk\debug 下生成的apk,要使用 Build -> Build Bundle(s)/APK(s) -> Build APK(s) 生成的 apk,否则会报以下错误:

Failure [INSTALL_FAILED_TEST_ONLY: installPackageLI]

错误产生原因:

Android Studio 3.0会在 debug apk 的 manifest 文件 application 标签里自动添加 android:testOnly="true"属性

也可以在被测试 apk 所在项目的 gradle.properties (项目根目录或者 gradle 全局配置目录 ~/.gradle/) 文件中添加:

android.injected.testOnly=false

添加后就可以直接使用启动按钮在目录 app\build\outputs\apk\debug 下生成的apk

静默安装代码
class MainActivity : AppCompatActivity() {

    companion object {
        private const val TAG = "MainActivity"
        private const val PACKAGE_INSTALLED_ACTION =
            "com.example.android.apis.content.SESSION_API_PACKAGE_INSTALLED"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        button.setOnClickListener {
            var session: PackageInstaller.Session? = null
            try {
                // 创建一个 session
                val packageInstaller = packageManager.packageInstaller
                val params = SessionParams(SessionParams.MODE_FULL_INSTALL)
                val sessionId = packageInstaller.createSession(params)
                session = packageInstaller.openSession(sessionId)

                // 将 apk 文件写入 session
                addApkToInstallSession("app-debug.apk", session)

                // 创建一个安装状态接收者,当 session 状态变化时,会回调这个接收者
                val context: Context = this@MainActivity
                val intent = Intent(context, MainActivity::class.java)
                intent.action = PACKAGE_INSTALLED_ACTION
                val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
                val statusReceiver = pendingIntent.intentSender

                // 提交会话,开始安装流程
                session.commit(statusReceiver)
            } catch (e: IOException) {
                throw RuntimeException("Couldn't install package", e)
            } catch (e: RuntimeException) {
                session?.abandon()
                throw e
            }
        }
    }

    @Throws(IOException::class)
    private fun addApkToInstallSession(assetName: String, session: PackageInstaller.Session) {
        // It's recommended to pass the file size to openWrite(). Otherwise installation may fail
        // if the disk is almost full.
        session.openWrite("package", 0, -1).use { outputStream ->
            assets.open(assetName).use { inputStream ->
                val buffer = ByteArray(16384)
                var length = 0
                while (inputStream.read(buffer).also { length = it } > 0) {
                    outputStream.write(buffer, 0, length)
                }
            }

            //如果 apk 实在某个路径下,就用下面的方法
            /*val apkPath = cacheDir.canonicalPath + "/app-debug.apk"
            FileInputStream(apkPath).use { inputStream ->
                val buffer = ByteArray(16384)
                var length = 0
                while (inputStream.read(buffer).also { length = it } > 0) {
                    outputStream.write(buffer, 0, length)
                }
            }*/
        }
    }

    // 这个 Activity 的启动模式 launchMode 必须为 singleTop,否则不会回调到这个 onNewIntent() 方法
    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        val extras = intent.extras
        if (PACKAGE_INSTALLED_ACTION == intent.action) {
            val status = extras.getInt(PackageInstaller.EXTRA_STATUS)
            val message = extras.getString(PackageInstaller.EXTRA_STATUS_MESSAGE)
            when (status) {
                PackageInstaller.STATUS_PENDING_USER_ACTION -> {
                    // 需要用户操作确认
                    val confirmIntent = extras[Intent.EXTRA_INTENT] as Intent
                    startActivity(confirmIntent)
                }
                PackageInstaller.STATUS_SUCCESS -> {
                    Toast.makeText(this, "Install succeeded!", Toast.LENGTH_SHORT).show()
                }
                PackageInstaller.STATUS_FAILURE,
                PackageInstaller.STATUS_FAILURE_ABORTED,
                PackageInstaller.STATUS_FAILURE_BLOCKED,
                PackageInstaller.STATUS_FAILURE_CONFLICT,
                PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
                PackageInstaller.STATUS_FAILURE_INVALID,
                PackageInstaller.STATUS_FAILURE_STORAGE -> {
                    Toast.makeText(this, "Install failed! $status, $message", Toast.LENGTH_SHORT)
                        .show()
                }
                else -> {
                    Toast.makeText(
                        this,
                        "Unrecognized status received from installer: $status",
                        Toast.LENGTH_SHORT
                    ).show()
                }
            }
        }
    }
}
apk 中存放资源

代码中的测试 apk 放在 main/assets 目录下,这里就说一下安卓 apk 中存放资源的方式,有两种路径 res/raw 目录和 main/assets 目录,两者目录下的文件在打包后会原封不动的保存在 apk 包中,不会被编译成二进制,有以下区别

区别 res/raw 目录 main/assets 目录
是否映射到 R.java 是,可使用 R.id.filename
是否可以再建立文件夹
读取操作(都返回 InputStream) resources.openRawResource(R.raw.test) assets.open("testAsset.txt")
文件名大小写 只能小写 大小写都可以
存放资源的读取代码示例

读取操作出来上面表格中的方法,还可以使用下面的方法

//读取 res/raw 目录文件
resources.openRawResourceFd(R.raw.test_raw).createInputStream()
//读取 main/assets 目录文件
assets.openFd("testAsset.txt").createInputStream()

注意:对于 assets 中的文件,aapt 会选择性的对其进行压缩,以减少apk的大小。

在 aosp 源码 aosp/frameworks/base/tools/aapt/Package.cpp 文件中记录了不会被压缩的文件

/* these formats are already compressed, or don't compress well */
static const char* kNoCompressExt[] = {
    ".jpg", ".jpeg", ".png", ".gif", ".opus",
    ".wav", ".mp2", ".mp3", ".ogg", ".aac",
    ".mpg", ".mpeg", ".mid", ".midi", ".smf", ".jet",
    ".rtttl", ".imy", ".xmf", ".mp4", ".m4a",
    ".m4v", ".3gp", ".3gpp", ".3g2", ".3gpp2",
    ".amr", ".awb", ".wma", ".wmv", ".webm", ".mkv"
};

因此使用 assets.openFd("testAsset.txt").createInputStream() 方法无法直接读取 .txt 文件内容,直接读取会报以下错误:

Caused by: java.io.FileNotFoundException: This file can not be opened as a file descriptor; it is probably compressed

而使用 assets.open("testAsset.txt") 方式读取则不会报错,因此尽量使用这个方法

也可以让 aapt 不对 txt 文件进行压缩,需要在 app/build.gradle 中的 android{} 块中内添加以下内容,这样 assets.openFd("testAsset.txt").createInputStream() 方法就可以直接读取 .txt 文件内容了

android {
    aaptOptions {
        noCompress "txt"  //表示不让aapt压缩的文件后缀
    }
    ......
}

代码

//读取 raw 内容目录下 test_raw.txt 文件内容
var rawInputStream = resources.openRawResource(R.raw.test_raw)
val rawContent = rawInputStream.bufferedReader().readLine()
Log.d(TAG, "onCreate: rawContent=$rawContent")

//读取 assets 目录下 testAsset.txt 文件内容
var assetInputStream = assets.open("testAsset.txt")
val assetContent = assetInputStream.bufferedReader().readLine()
Log.d(TAG, "onCreate: assetContent=$assetContent")

你可能感兴趣的:(android 之 Intent)