一、使用通知 通知(notification)是Android系统中比较有特色的一个功能,当某个应用程序希望向用户发出一些提示信息,而该应用程序又不在前台运行时,就可以借助通知来实现。发出一条通知后,手机最上方的状态栏中会显示一个通知的图标,下拉状态栏后可以看到通知的详细内容。Android的通知功能自推出以来就大获成功,连iOS系统也在5.0版本之后加入了类似的功能。
1.1 创建通知渠道 Android 8.0系统引入了通知渠道这个概念。什么是通知渠道呢?顾名思义,就是每条通知都要属于一个对应的渠道。每个应用程序都可以自由地创建当前应用拥有哪些通知渠道,但是这些通知渠道的控制权是掌握在用户手上的。用户可以自由地选择这些通知渠道的重要程度,是否响铃、是否振动或者是否要关闭这个渠道的通知。
拥有了这些控制权之后,用户就再也不用害怕那些垃圾通知的打扰了,因为用户可以自主地选择关心哪些通知、不关心哪些通知。
而我们的应用程序如果想要发出通知,也必须创建自己的通知渠道才行,下面我们就来学习一下创建通知渠道的详细步骤。
首先需要一个NotificationManager对通知进行管理,可以通过调用Context的getSystemService()方法获取。getSystemService()方法接收一个字符串参数用于确定获取系统的哪个服务,这里我们传入Context.NOTIFICATION_SERVICE即可。因此,获取NotificationManager的实例就可以写成:
1 val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
接下来要使用NotificationChannel类构建一个通知渠道,并调用NotificationManager的createNotificationChannel()方法完成创建。由于NotificationChannel类和createNotificationChannel()方法都是Android 8.0系统中新增的API,因此我们在使用的时候还需要进行版本判断才可以,写法如下:
1 2 3 4 if (Build.VERSION.SDK_INT >= Build.VERSION_CODE.O) { val channel = NotificationChannel(channelId, channelName, importance) manager.createNotificationChannel(channel) }
创建一个通知渠道至少需要渠道ID 、渠道名称 以及重要等级 这3个参数 ,其中渠道ID可以随便定义,只要保证全局唯一性就可以 。渠道名称是给用户看的,需要可以清楚地表达这个渠道的用途 。通知的重要等级主要有IMPORTANCE_HIGH、IMPORTANCE_DEFAULT、IMPORTANCE_LOW、IMPORTANCE_MIN这几种,对应的重要程度依次从高到低 。不同的重要等级会决定通知的不同行为,后面我们会通过具体的例子进行演示。当然这里只是初始状态下的重要等级,用户可以随时手动更改某个通知渠道的重要等级,开发者是无法干预的。
下面来看一下通知的使用方法。通知的用法还是比较灵活的,既可以在Activity里创建,也可以在BroadcastReceiver里创建,当然还可以在后面我们即将学习的Service里创建。相比于BroadcastReceiver和Service,在Activity里创建通知的场景还是比较少的,因为一般只有当程序进入后台的时候才需要使用通知。
1.2 通知的基本用法 不过,无论是在哪里创建通知,整体的步骤都是相同的,下面我们就来看一下创建通知的详细步骤。
首先需要使用一个Builder构造器来创建Notification对象,但问题在于,Android系统的每一个版本都会对通知功能进行或多或少的修改,API不稳定的问题在通知上凸显得尤其严重,比方说刚刚介绍的通知渠道功能在Android 8.0系统之前就是没有的。那么该如何解决这个问题呢?其实解决方案我们之前已经见过好几回了,就是使用AndroidX库中提供的兼容API。AndroidX库中提供了一个NotificationCompat类,使用这个类的构造器创建Notification对象,就可以保证我们的程序在所有Android系统版本上都能正常工作了,代码如下所示 :
1 val notification = NotificationCompat.Builder(context, channelId).build()
NotificationCompat.Builder的构造函数中接收两个参数:第一个参数是context ,这个没什么好说的;第二个参数是渠道ID ,需要和我们在创建通知渠道时指定的渠道ID相匹配才行。
当然,上述代码只是创建了一个空的Notification对象,并没有什么实际作用,我们可以在最终的build()方法之前连缀任意多的设置方法来创建一个丰富的Notification对象,先来看一些最基本的设置:
1 2 3 4 5 val notification = NotificationCompat.Builder(context, channelId).setContentTitle("this is content title" ).setContentText("This is content text" ) .setSmallIcon(R.drawable.small_icon) .setLargeIcon(BitmapFactory.decodeResource(getResources(),R.drawable.large_icon)) .build()
上述代码中一共调用了4个设置方法,下面我们来一一解析一下。setContentTitle()方法用于指定通知的标题内容,下拉系统状态栏就可以看到这部分内容。setContentText()方法用于指定通知的正文内容,同样下拉系统状态栏就可以看到这部分内容。setSmallIcon()方法用于设置通知的小图标,注意,只能使用纯alpha图层的图片进行设置,小图标会显示在系统状态栏上。setLargeIcon()方法用于设置通知的大图标,当下拉系统状态栏时,就可以看到设置的大图标了。
以上工作都完成之后,只需要调用NotificationManager的notify()方法就可以让通知显示出来了。notify()方法接收两个参数:第一个参数是id ,要保证为每个通知指定的id都是不同的;第二个参数则是Notification对象 ,这里直接将我们刚刚创建好的Notification对象传入即可。因此,显示一个通知就可以写成:
1 manager.notify(1 ,notification)
下面就让我们通过一个具体的例子来看看通知到底是长什么样的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 package com.ubx.notificationtestimport android.app.NotificationChannelimport android.app.NotificationManagerimport android.content.Contextimport android.content.Intentimport android.graphics.BitmapFactoryimport android.net.Uriimport android.os.Buildimport android.os.Bundleimport android.provider.Settingsimport androidx.appcompat.app.AlertDialogimport androidx.appcompat.app.AppCompatActivityimport androidx.core.app.NotificationCompatimport androidx.core.app.NotificationManagerCompatimport com.ubx.notificationtest.databinding.ActivityMainBindingimport kotlin.random.Randomclass MainActivity : AppCompatActivity () { private lateinit var mainBinding: ActivityMainBinding override fun onCreate (savedInstanceState: Bundle ?) { super .onCreate(savedInstanceState) mainBinding = ActivityMainBinding.inflate(layoutInflater) setContentView(mainBinding.root) val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel("normal" , "Normal" , NotificationManager.IMPORTANCE_DEFAULT) manager.createNotificationChannel(channel) } mainBinding.sendNotice.setOnClickListener { if (!NotificationManagerCompat.from(this ).areNotificationsEnabled()) { AlertDialog.Builder(this ) .setTitle("需要通知权限" ) .setMessage("此应用需要您允许通知权限才能正常工作。请点击“设置”按钮,然后在设置中开启通知权限。" ) .setPositiveButton("设置" ) { _, _ -> val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) val uri = Uri.fromParts("package" , packageName, null ) intent.data = uri startActivity(intent) } .setNegativeButton("取消" ) { _, _ -> finish() } .show() } else { val notification = NotificationCompat.Builder(this , "normal" ) .setContentTitle("this is content title" ) .setContentText("this is content text" .repeat(Random.nextInt(5 , 10 ))) .setSmallIcon(R.drawable.small_icon) .setLargeIcon( BitmapFactory.decodeResource( resources, R.drawable.large_icon ) ) .build() manager.notify(1 , notification) } } } companion object { const val TAG = "MainActivity" } }
如果你使用过Android手机,此时应该会下意识地认为这条通知是可以点击的。但是当你去点击它的时候,会发现没有任何效果。不对啊,每条通知被点击之后都应该有所反应呀。其实要想实现通知的点击效果,我们还需要在代码中进行相应的设置,这就涉及了一个新的概念——PendingIntent。
PendingIntent从名字上看起来就和Intent有些类似,它们确实存在不少共同点。比如它们都可以指明某一个“意图”,都可以用于启动Activity、启动Service以及发送广播等。不同的是,Intent倾向于立即执行某个动作,而PendingIntent倾向于在某个合适的时机执行某个动作。所以,也可以把PendingIntent简单地理解为**延迟执行的Intent**。
PendingIntent的用法同样很简单,它主要提供了几个静态方法用于获取PendingIntent的实例,可以根据需求来选择是使用getActivity()方法、getBroadcast()方法,还是getService()方法。这几个方法所接收的参数都是相同的:第一个参数依旧是Context,不用多做解释;第二个参数一般用不到,传入0即可;第三个参数是一个Intent对象,我们可以通过这个对象构建出PendingIntent的“意图”;第四个参数用于确定PendingIntent的行为,有FLAG_ONE_SHOT、FLAG_NO_CREATE、FLAG_CANCEL_CURRENT和FLAG_UPDATE_CURRENT这4种标志可选.这些标志是用来控制 PendingIntent 行为的。下面是每个标志的含义:
FLAG_ONE_SHOT:这个标志表示返回的 PendingIntent 只能使用一次。如果后续还需要执行相同的操作,你需要再次获取一个新的 PendingIntent。
FLAG_NO_CREATE:如果当前的 PendingIntent 不存在,那么简单地返回 null,而不是创建一个新的 PendingIntent。
FLAG_CANCEL_CURRENT:这个标志表示当前的 PendingIntent 会被取消,然后创建一个新的 PendingIntent。这意味着旧的 PendingIntent 不再有效,所有的等待的 Intent 都会被取消。
FLAG_UPDATE_CURRENT:如果相同的 PendingIntent 已经存在,那么保持它不变,但是替换它的 Intent 数据。这意味着新的 Intent 数据会被用来更新已经存在的 PendingIntent。
从 Android 12(API 级别 31)开始,创建 PendingIntent 时必须指定 FLAG_IMMUTABLE 或 FLAG_MUTABLE。这是因为 Android 12 对 PendingIntent 的行为进行了更改,以提高应用的安全性。
对PendingIntent有了一定的了解后,我们再回过头来看一下NotificationCompat.Builder。这个构造器还可以连缀一个setContentIntent()方法,接收的参数正是一个PendingIntent对象。因此,这里就可以通过PendingIntent构建一个延迟执行的“意图”,当用户点击这条通知时就会执行相应的逻辑。
现在我们来优化一下NotificationTest项目,给刚才的通知加上点击功能,让用户点击它的时候可以启动另一个Activity。
FLAG_IMMUTABLE:这个标志表示 PendingIntent 是不可变的,也就是说,一旦创建,就不能更改。这是推荐的选项,因为它可以防止潜在的安全问题。
FLAG_MUTABLE:这个标志表示 PendingIntent 是可变的,也就是说,可以在创建后进行更改。只有在某些功能依赖于 PendingIntent 的可变性时,才应该使用这个选项,例如,需要与内联回复或气泡一起使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 package com.ubx.notificationtestimport android.app.NotificationChannelimport android.app.NotificationManagerimport android.app.PendingIntentimport android.content.Contextimport android.content.Intentimport android.graphics.BitmapFactoryimport android.net.Uriimport android.os.Buildimport android.os.Bundleimport android.provider.Settingsimport androidx.appcompat.app.AlertDialogimport androidx.appcompat.app.AppCompatActivityimport androidx.core.app.NotificationCompatimport androidx.core.app.NotificationManagerCompatimport com.ubx.notificationtest.databinding.ActivityMainBindingimport kotlin.random.Randomclass MainActivity : AppCompatActivity () { private lateinit var mainBinding: ActivityMainBinding override fun onCreate (savedInstanceState: Bundle ?) { super .onCreate(savedInstanceState) mainBinding = ActivityMainBinding.inflate(layoutInflater) setContentView(mainBinding.root) val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel("normal" , "Normal" , NotificationManager.IMPORTANCE_DEFAULT) manager.createNotificationChannel(channel) } mainBinding.sendNotice.setOnClickListener { if (!NotificationManagerCompat.from(this ).areNotificationsEnabled()) { AlertDialog.Builder(this ) .setTitle("需要通知权限" ) .setMessage("此应用需要您允许通知权限才能正常工作。请点击“设置”按钮,然后在设置中开启通知权限。" ) .setPositiveButton("设置" ) { _, _ -> val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) val uri = Uri.fromParts("package" , packageName, null ) intent.data = uri startActivity(intent) } .setNegativeButton("取消" ) { _, _ -> finish() } .show() } else { val intent = Intent(this , NotificationActivity::class .java) val pi = PendingIntent.getActivity(this , 0 , intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) val notification = NotificationCompat.Builder(this , "normal" ) .setContentTitle("this is content title" ) .setContentText("this is content text" .repeat(Random.nextInt(5 , 10 ))) .setSmallIcon(R.drawable.small_icon) .setLargeIcon( BitmapFactory.decodeResource( resources, R.drawable.large_icon ) ) .setContentIntent(pi) .build() manager.notify(1 , notification) } } } companion object { const val TAG = "MainActivity" } }
如果我们没有在代码中对该通知进行取消,它就会一直显示在系统的状态栏上。解决的方法有两种:一种是在NotificationCompat.Builder中再连缀一个setAutoCancel()方法,一种是显式地调用NotificationManager的cancel()方法将它取消。两种方法我们都学习一下。
第一种方法写法如下:
1 2 3 4 val notification = NotificationCompat.Builder(this , "normal" ) ... .setAutoCancel(true ) .build()
setAutoCancel()方法传入true,就表示当点击这个通知的时候,通知会自动取消。
第二种方法写法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.ubx.notificationtestimport android.app.NotificationManagerimport android.content.Contextimport androidx.appcompat.app.AppCompatActivityimport android.os.Bundleclass NotificationActivity : AppCompatActivity () { override fun onCreate (savedInstanceState: Bundle ?) { super .onCreate(savedInstanceState) setContentView(R.layout.activity_notification) val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager manager.cancel(1 ) } }
这里我们在cancel()方法中传入了1,当时我们给这条通知设置的id就是1。因此,如果你想取消哪条通知,在cancel()方法中传入该通知的id就行了。
1.3 通知的进阶技巧 当我们想在通知中显示较长的富文本时会发现,Android会将通知中的长文本折叠起来。那么有没有什么办法把富文本全部显示出来呢?实际上,NotificationCompat.Builder中提供了非常丰富的API,以便我们创建出更加多样的通知效果。先来看看setStyle()方法,这个方法允许我们构建出富文本的通知内容。也就是说,通知中不光可以有文字和图标,还可以包含更多的东西。setStyle()方法接收一个NotificationCompat.Style参数,这个参数就是用来构建具体的富文本信息的,如长文字、图片等。
1 2 3 4 val notification = NotificationCompat.Builder(this , "normal" ) ... .setStyle(NotificationCompat.BigTextStyle().bigText("Learn how to build notifications, send and sync data, and use voice actions. Get the official Android IDE and developer tools to build apps for Android." )) .build()
这里使用了setStyle()方法替代setContentText()方法。在setStyle()方法中,我们创建了一个NotificationCompat.BigTextStyle对象,这个对象就是用于封装长文字信息的,只要调用它的bigText()方法并将文字内容传入就可以了。
除了显示长文字之外,通知里还可以显示一张大图片,具体用法是基本相似的:
1 2 3 4 val notification = NotificationCompat.Builder(this , "normal" ) ... .setStyle(NotificationCompat.BigPictureStyle().bigPicture(BitmapFactory.decodeResource(resources, R.drawable.big_image))) .build()
可以看到,这里仍然是调用的setStyle()方法,这次我们在参数中创建了一个NotificationCompat.BigPictureStyle对象,这个对象就是用于设置大图片的,然后调用它的bigPicture()方法并将图片传入。这里使用事先准备好的一张图片,通过BitmapFactory的decodeResource()方法将图片解析成Bitmap对象,再传入bigPicture()方法中就可以了。
接下来来学习一下不同重要等级的通知渠道对通知的行为具体有什么影响。其实简单来讲,就是通知渠道的重要等级越高,发出的通知就越容易获得用户的注意。比如高重要等级的通知渠道发出的通知可以弹出横幅、发出声音,而低重要等级的通知渠道发出的通知不仅可能会在某些情况下被隐藏,而且可能会被改变显示的顺序,将其排在更重要的通知之后。但需要注意的是,开发者只能在创建通知渠道的时候为它指定初始的重要等级,如果用户不认可这个重要等级的话,可以随时进行修改,开发者对此无权再进行调整和变更,因为通知渠道一旦创建就不能再通过代码修改了。
虽然无法更改之前创建的通知渠道,但是我们可以创建一个新的通知渠道,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class MainActivity : AppCompatActivity () { override fun onCreate (savedInstanceState: Bundle ?) { ... if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ... val channel2 = NotificationChannel("important" , "Important" , NotificationManager.IMPORTANCE_HIGH) manager.createNotificationChannel(channel2) } sendNotice.setOnClickListener { val intent = Intent(this , NotificationActivity::class .java) val pi = PendingIntent.getActivity(this , 0 , intent, 0 ) val notification = NotificationCompat.Builder(this , "important" ) ... } } }
二、调用摄像头和相册 我们平时在使用QQ或微信的时候经常要和别人分享图片,这些图片可以是用手机摄像头拍的,也可以是从相册中选取的。这样的功能实在是太常见了,几乎是应用程序必备的功能,那么本节我们就学习一下调用摄像头和相册方面的知识。
2.1 调用摄像头拍照 首先在布局文件中添加两个控件:一个Button和一个ImageView。Button是用于打开摄像头进行拍照的,而ImageView则是用于将拍到的图片显示出来。
开始编写调用摄像头的具体逻辑,修改MainActivity中的代码,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 package work.icu007.cameraalbumtestimport android.app.Activityimport android.content.Intentimport android.graphics.Bitmapimport android.graphics.BitmapFactoryimport android.graphics.BitmapShaderimport android.graphics.Canvasimport android.graphics.Matriximport android.graphics.Paintimport android.graphics.Shaderimport android.media.ExifInterfaceimport android.net.Uriimport android.os.Buildimport androidx.appcompat.app.AppCompatActivityimport android.os.Bundleimport android.provider.MediaStoreimport android.util.Logimport androidx.activity.result.ActivityResultLauncherimport androidx.activity.result.contract.ActivityResultContractsimport androidx.core.content.FileProviderimport com.ubx.cameraalbumtest.databinding.ActivityMainBindingimport java.io.Fileimport kotlin.properties.Delegatesclass MainActivity : AppCompatActivity () { private lateinit var imageUri: Uri private lateinit var outputImage: File private lateinit var mainBinding: ActivityMainBinding private lateinit var takePhoto: ActivityResultLauncher<Intent> override fun onCreate (savedInstanceState: Bundle ?) { super .onCreate(savedInstanceState) mainBinding = ActivityMainBinding.inflate(layoutInflater) setContentView(mainBinding.root) takePhoto = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == Activity.RESULT_OK) { val bitmap = BitmapFactory.decodeStream(contentResolver.openInputStream(imageUri)) if (bitmap != null ){ mainBinding.imageView.setImageBitmap(rotateIfRequired(bitmap)) } } else { Log.d(TAG, "onCreate: take Photo error" ) } } mainBinding.takePhotoBtn.setOnClickListener { outputImage = File(externalCacheDir, "output_image.jpg" ) if (outputImage.exists()) { outputImage.delete() } outputImage.createNewFile() imageUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { FileProvider.getUriForFile(this , "work.icu007.cameraalbumtest.fileprovider" , outputImage) } else { Uri.fromFile(outputImage) } val intent = Intent("android.media.action.IMAGE_CAPTURE" ) intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri) takePhoto.launch(intent) } } private fun rotateIfRequired (bitmap: Bitmap ) : Bitmap { val exif = ExifInterface(outputImage.path) val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) return when (orientation) { ExifInterface.ORIENTATION_ROTATE_90 -> rotateBitmap(bitmap, 90 ) ExifInterface.ORIENTATION_ROTATE_180 -> rotateBitmap(bitmap, 180 ) ExifInterface.ORIENTATION_ROTATE_270 -> rotateBitmap(bitmap, 270 ) else -> bitmap } } private fun rotateBitmap (bitmap: Bitmap , degree: Int ) : Bitmap { val matrix = Matrix() matrix.postRotate(degree.toFloat()) val rotatedBitmap = Bitmap.createBitmap(bitmap, 0 , 0 , bitmap.width, bitmap.height, matrix, true ) bitmap.recycle() return rotatedBitmap } companion object { const val TAG = "MainActivity" } }
在MainActivity中要做的第一件事自然是给Button注册点击事件,然后就是实例化 takePhoto 对象,并且注册返回结果的处理函数。紧接着在点击事件里开始处理调用摄像头的逻辑:
首先这里创建了一个File对象,用于存放摄像头拍下的图片,这里我们把图片命名为output_image.jpg,并存放在手机SD卡的应用关联缓存目录下。什么叫作应用关联缓存目录呢?就是指SD卡中专门用于存放当前应用缓存数据的位置,调用getExternalCacheDir()方法可以得到这个目录,具体的路径是/sdcard/Android/data/<package name>/cache。那么为什么要使用应用关联缓存目录来存放图片呢?因为从Android 6.0系统开始,读写SD卡被列为了危险权限,如果将图片存放在SD卡的任何其他目录,都要进行运行时权限处理才行,而使用应用关联目录则可以跳过这一步。另外,从Android 10.0系统开始,公有的SD卡目录已经不再允许被应用程序直接访问了,而是要使用作用域存储才行。
接着会进行一个判断,如果运行设备的系统版本低于Android 7.0,就调用Uri的fromFile()方法将File对象转换成Uri对象,这个Uri对象标识着output_image.jpg这张图片的本地真实路径。否则,就调用FileProvider的getUriForFile()方法将File对象转换成一个封装过的Uri对象。getUriForFile()方法接收3个参数:第一个参数要求传入Context对象,第二个参数可以是任意唯一的字符串,第三个参数则是我们刚刚创建的File对象。之所以要进行这样一层转换,是因为从Android 7.0系统开始,直接使用本地真实路径的Uri被认为是不安全的,会抛出一个FileUriExposedException异常。而FileProvider则是一种特殊的ContentProvider,它使用了和ContentProvider类似的机制来对数据进行保护,可以选择性地将封装过的Uri共享给外部,从而提高了应用的安全性。
接下来构建了一个Intent对象,并将这个Intent的action指定为android.media.action.IMAGE_CAPTURE,再调用Intent的putExtra()方法指定图片的输出地址,这里填入刚刚得到的Uri对象,最后调用ActivityResultLauncher对象的launch()方法来启动相机应用进行捕获图片。
调用照相机程序去拍照有可能会在一些手机上发生照片旋转的情况。这是因为这些手机认为打开摄像头进行拍摄时手机就应该是横屏的,因此回到竖屏的情况下就会发生90度的旋转。为此,这里我们又加上了判断图片方向的代码,如果发现图片需要进行旋转,那么就先将图片旋转相应的角度,然后再显示到界面上。
刚才提到了ContentProvider,那么我们自然要在AndroidManifest.xml中对它进行注册才行,代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 <?xml version="1.0" encoding="utf-8" ?> <manifest xmlns:android ="http://schemas.android.com/apk/res/android" xmlns:tools ="http://schemas.android.com/tools" > <application android:allowBackup ="true" android:dataExtractionRules ="@xml/data_extraction_rules" android:fullBackupContent ="@xml/backup_rules" android:icon ="@mipmap/ic_launcher" android:label ="@string/app_name" android:roundIcon ="@mipmap/ic_launcher_round" android:supportsRtl ="true" android:theme ="@style/Theme.CameraAlbumTest" tools:targetApi ="31" > <provider android:name ="androidx.core.content.FileProvider" android:authorities ="work.icu007.cameraalbumtest.fileprovider" android:exported ="false" android:grantUriPermissions ="true" > <meta-data android:name ="android.support.FILE_PROVIDER_PATHS" android:resource ="@xml/file_paths" /> </provider > <activity android:name =".MainActivity" android:exported ="true" > <intent-filter > <action android:name ="android.intent.action.MAIN" /> <category android:name ="android.intent.category.LAUNCHER" /> </intent-filter > </activity > </application > </manifest >
android:name属性的值是固定的,而android:authorities属性的值必须和刚才FileProvider.getUriForFile()方法中的第二个参数一致。另外,这里还在<provider>标签的内部使用<meta-data>指定Uri的共享路径,并引用了一个@xml/file_paths资源。当然,这个资源现在还是不存在的,下面我们就来创建它。
右击res目录→New→Directory,创建一个xml目录,接着右击xml目录→New→File,创建一个file_paths.xml文件。然后修改file_paths.xml文件中的内容,如下所示:
1 2 3 4 5 6 <?xml version="1.0" encoding="utf-8" ?> <paths > <external-path name ="my_images" path ="/" /> </paths >
external-path就是用来指定Uri共享路径的,name属性的值可以随便填,path属性的值表示共享的具体路径。这里使用一个单斜线表示将整个SD卡进行共享 ,当然你也可以仅共享存放output_image.jpg这张图片的路径。
2.2 从相册选择图片 还是在 2.1 项目基础上修改,新增一个button用于从相册中选择图片。
修改mainActivity代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 package com.ubx.cameraalbumtestimport android.app.Activityimport android.content.Intentimport android.graphics.Bitmapimport android.graphics.BitmapFactoryimport android.graphics.BitmapShaderimport android.graphics.Canvasimport android.graphics.Matriximport android.graphics.Paintimport android.graphics.Shaderimport android.media.ExifInterfaceimport android.net.Uriimport android.os.Buildimport androidx.appcompat.app.AppCompatActivityimport android.os.Bundleimport android.provider.MediaStoreimport android.util.Logimport androidx.activity.result.ActivityResultLauncherimport androidx.activity.result.contract.ActivityResultContractsimport androidx.core.content.FileProviderimport com.ubx.cameraalbumtest.databinding.ActivityMainBindingimport java.io.Fileimport kotlin.properties.Delegatesclass MainActivity : AppCompatActivity () { private lateinit var imageUri: Uri private lateinit var outputImage: File private lateinit var mainBinding: ActivityMainBinding private lateinit var takePhoto: ActivityResultLauncher<Intent> private lateinit var fromAlbum: ActivityResultLauncher<Intent> override fun onCreate (savedInstanceState: Bundle ?) { super .onCreate(savedInstanceState) mainBinding = ActivityMainBinding.inflate(layoutInflater) setContentView(mainBinding.root) ... fromAlbum = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {result -> if (result.resultCode == Activity.RESULT_OK && result.data != null ){ result.data !!.data ?.let { uri -> val bitmap = BitmapFactory.decodeStream(contentResolver.openInputStream(uri)) mainBinding.imageView.setImageBitmap(bitmap) } } } ... mainBinding.fromAlbumBtn.setOnClickListener { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) intent.addCategory(Intent.CATEGORY_OPENABLE) intent.type = "image/*" fromAlbum.launch(intent) } } ... companion object { const val TAG = "MainActivity" } }
在“From Album”按钮的点击事件里,我们先构建了一个Intent对象,并将它的action指定为Intent.ACTION_OPEN_DOCUMENT,表示打开系统的文件选择器。接着给这个Intent对象设置一些条件过滤,只允许可打开的图片文件显示出来,然后调用fromAlbum.launch(intent),打开文件选择器。
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {result ->...}:在这段代码中,使用registerForActivityResult方法注册了一个回调函数。这个方法接收两个参数,一个是要启动的Activity的类型描述,另一个是回调函数。此处的ActivityResultContracts.StartActivityForResult()表示启动的Activity是为了返回数据。在回调函数中,参数result即代表启动Activity的结果。
在回调函数中,首先检查了result.resultCode == Activity.RESULT_OK && result.data != null,这也是常见的模式,意味着返回的结果有效且有返回数据。在此条件满足的情况下,result.data!!.data?.let { uri ->...} 这段代码解释了对返回的数据result.data.data即图片的URI的处理。使用let是Kotlin语言中的函数式编程风格,当result.data!!.data不为空时,执行其代码块,此处的uri即代表图片的URI。
val bitmap = BitmapFactory.decodeStream(contentResolver.openInputStream(uri))这段代码使用BitmapFactory解码stream得到一个bitmap,而stream是使用contentResolver从图片的uri中获取到的。
mainBinding.imageView.setImageBitmap(bitmap)将解码得到的bitmap设为imageView的图像显示。
三、播放多媒体文件 手机上最常见的休闲方式毫无疑问就是听音乐和看电影了,随着移动设备的普及,越来越多的人可以随时享受优美的音乐,观看精彩的电影。Android在播放音频和视频方面做了相当不错的支持,它提供了一套较为完整的API,使得开发者可以很轻松地编写出一个简易的音频或视频播放器,下面我们就来具体地学习一下。
3.1 播放音频 在Android中播放音频文件一般是使用MediaPlayer类实现的,它对多种格式的音频文件提供了非常全面的控制方法,从而使播放音乐的工作变得十分简单。下表列出了MediaPlayer类中一些较为常用的控制方法。
方法名
功能描述
setDataSource()
设置要播放的音频文件的位置
prepare()
在开始播放之前调用,以完成准备工作
start()
开始或继续播放音频
pause()
暂停播放音频
reset()
将MediaPlayer对象重置到刚刚创建的状态
seekTo()
从指定的位置开始播放音频
stop()
停止播放音频。调用后的MediaPlayer对象无法再播放音频
release()
释放与MediaPlayer对象相关的资源
isPlaying()
判断当前MediaPlayer是否正在播放音频
getDuration()
获取载入的音频文件的时长
简单了解了上述方法后,我们再来梳理一下MediaPlayer的工作流程。首先需要创建一个MediaPlayer对象,然后调用setDataSource()方法设置音频文件的路径,再调用prepare()方法使MediaPlayer进入准备状态,接下来调用start()方法就可以开始播放音频,调用pause()方法就会暂停播放,调用reset()方法就会停止播放。下面就让我们通过一个具体的例子来学习一下,代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 package work.icu007.playaudiotestimport android.media.MediaPlayerimport android.os.Bundleimport androidx.appcompat.app.AppCompatActivityimport work.icu007.playaudiotest.databinding.ActivityMainBindingclass MainActivity : AppCompatActivity () { private lateinit var mainBinding: ActivityMainBinding private val mediaPlayer = MediaPlayer() override fun onCreate (savedInstanceState: Bundle ?) { super .onCreate(savedInstanceState) mainBinding = ActivityMainBinding.inflate(layoutInflater) setContentView(mainBinding.root) initMediaPlayer() mainBinding.play.setOnClickListener { if (!mediaPlayer.isPlaying) { mediaPlayer.start() } } mainBinding.pause.setOnClickListener { if (mediaPlayer.isPlaying) { mediaPlayer.pause() } } mainBinding.stop.setOnClickListener { if (mediaPlayer.isPlaying) { mediaPlayer.reset() initMediaPlayer() } } } private fun initMediaPlayer () { val assetManager = assets val fd = assetManager.openFd("人生浪费指南 - 夏日入侵企画.mp3" ) mediaPlayer.setDataSource(fd.fileDescriptor, fd.startOffset, fd.length) mediaPlayer.prepare() } override fun onDestroy () { super .onDestroy() mediaPlayer.stop() mediaPlayer.release() } }
在类初始化的时候,我们就先创建了一个MediaPlayer的实例,然后在onCreate()方法中调用initMediaPlayer()方法,为MediaPlayer对象进行初始化操作。在initMediaPlayer()方法中,首先通过getAssets()方法得到了一个AssetManager的实例,AssetManager可用于读取assets目录下的任何资源。接着我们调用了openFd()方法将音频文件句柄打开,后面又依次调用了setDataSource()方法和prepare()方法,为MediaPlayer做好了播放前的准备。
当点击“Play”按钮时会进行判断,如果当前MediaPlayer没有正在播放音频,则调用start()方法开始播放。当点击“Pause”按钮时会判断,如果当前MediaPlayer正在播放音频,则调用pause()方法暂停播放。当点击“Stop”按钮时会判断,如果当前MediaPlayer正在播放音频,则调用reset()方法将MediaPlayer重置为刚刚创建的状态,然后重新调用一遍initMediaPlayer()方法。最后在onDestroy()方法中,我们还需要分别调用stop()方法和release()方法,将与MediaPlayer相关的资源释放掉。
3.2 播放视频 播放视频文件其实并不比播放音频文件复杂,主要是使用VideoView类来实现的。这个类将视频的显示和控制集于一身,我们仅仅借助它就可以完成一个简易的视频播放器。VideoView的用法和MediaPlayer也比较类似,常用方法如下表所示。
方法名
功能描述
setVideoPath()
设置要播放的视频文件的位置
start()
开始或继续播放视频
pause()
暂停播放视频
resume()
将视频从头开始播放
seekTo()
从指定的位置开始播放视频
isPlaying()
判断当前是否正在播放视频
getDuration()
获取载入的视频文件的时长
suspend()
释放ViedoView所占用的资源
编辑布局文件如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 <?xml version="1.0" encoding="utf-8" ?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android ="http://schemas.android.com/apk/res/android" xmlns:app ="http://schemas.android.com/apk/res-auto" xmlns:tools ="http://schemas.android.com/tools" android:layout_width ="match_parent" android:layout_height ="match_parent" tools:context =".MainActivity" > <Button android:id ="@+id/play" android:layout_width ="wrap_content" android:layout_height ="wrap_content" android:text ="Play" android:layout_margin ="10dp" app:layout_constraintTop_toTopOf ="parent" app:layout_constraintStart_toStartOf ="parent" app:layout_constraintEnd_toStartOf ="@id/pause" app:layout_constraintBottom_toTopOf ="@id/videoView" /> <Button android:id ="@+id/pause" android:layout_width ="wrap_content" android:layout_height ="wrap_content" android:text ="Pause" android:layout_margin ="10dp" app:layout_constraintTop_toTopOf ="parent" app:layout_constraintStart_toEndOf ="@id/play" app:layout_constraintEnd_toStartOf ="@id/reply" app:layout_constraintBottom_toTopOf ="@id/videoView" /> <Button android:id ="@+id/reply" android:layout_width ="wrap_content" android:layout_height ="wrap_content" android:text ="Reply" android:layout_margin ="10dp" app:layout_constraintTop_toTopOf ="parent" app:layout_constraintStart_toEndOf ="@id/pause" app:layout_constraintEnd_toEndOf ="parent" app:layout_constraintBottom_toTopOf ="@id/videoView" /> <VideoView android:id ="@+id/videoView" android:layout_width ="match_parent" android:layout_height ="0dp" app:layout_constraintTop_toBottomOf ="@id/pause" app:layout_constraintBottom_toBottomOf ="parent" /> </androidx.constraintlayout.widget.ConstraintLayout >
而后修改MainActivity中的代码,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 package work.icu007.playvideotestimport android.net.Uriimport androidx.appcompat.app.AppCompatActivityimport android.os.Bundleimport android.util.Logimport android.widget.MediaControllerimport work.icu007.playvideotest.databinding.ActivityMainBindingclass MainActivity : AppCompatActivity () { lateinit var mainBinding: ActivityMainBinding override fun onCreate (savedInstanceState: Bundle ?) { super .onCreate(savedInstanceState) mainBinding = ActivityMainBinding.inflate(layoutInflater) setContentView(mainBinding.root) val uri = Uri.parse("android.resource://$packageName /${R.raw.video} " ) Log.d(TAG, "onCreate: uri: $uri " ) mainBinding.videoView.setVideoURI(uri) val mediaController = MediaController(this ) mainBinding.videoView.setMediaController(mediaController) mainBinding.play.setOnClickListener { if (!mainBinding.videoView.isPlaying){ mainBinding.videoView.start() } } mainBinding.pause.setOnClickListener { if (mainBinding.videoView.isPlaying){ mainBinding.videoView.pause() } } mainBinding.reply.setOnClickListener { if (mainBinding.videoView.isPlaying){ mainBinding.videoView.resume() } } } override fun onDestroy () { super .onDestroy() mainBinding.videoView.suspend () } companion object { var TAG = this .javaClass.simpleName } }
首先在onCreate()方法中调用了Uri.parse()方法,将raw目录下的video.mp4文件解析成了一个Uri对象,这里使用的写法是Android要求的固定写法。然后调用VideoView的setVideoURI()方法将刚才解析出来的Uri对象传入,这样VideoView就初始化完成了。
当点击“Play”按钮时会判断,如果当前没有正在播放视频,则调用start()方法开始播放。当点击“Pause”按钮时会判断,如果当前视频正在播放,则调用pause()方法暂停播放。当点击“Replay”按钮时会判断,如果当前视频正在播放,则调用resume()方法从头播放视频。最后在onDestroy()方法中,我们还需要调用一下suspend()方法,将VideoView所占用的资源释放掉。还添加了一个MediaController。MediaController可以提供一套控制面板,用户可以用它来播放、暂停和快进视频。