总体介绍
一、基础介绍
1、什么是Jetpack Compose
Jetpack Compose 是Google新发布的用于Android开发的UI工具包。用于替换我们目前常用的XML布局文件搭配findById的UI绘制方法。属于是一个很大的变动了。
compose属于声明式语法,附官方介绍:Jetpack Compose 用更少的代码、强大的工具和直观的 Kotlin API 简化并加速了 Android 上的 UI 开发。就是可以直接在activity里,去“绘制”当前页面布局,比XML布局来回findById确实强不少。能省不少模板代码。
值得一提的是,目前华为的arkui也是声明式语法,鸿蒙和安卓这两个操作系统,虽然采用不同的开发语言,不同的开发框架,但是在UI开发上,却提供了一致的方案,说明声明式UI语法,确实是大势所趋。
2、Kotlin
Kotlin就比较被人所知了,早在2017年的Google I/O大会上,就官宣了成为android开发的官方语言。所以现在还不用kotlin开发app,都会显得有点out。
kotlin作为一个基于JVM的语言,天生就对java兼容,就是说一个项目可以用java和kotlin混合编程,在kotlin的类中,可以直接调用java类文件里的方法。
3、项目简介
好嘞,接下来说说练手项目,我计划是获取系统的通知消息,比如微信消息,微博热搜什么的。把各种推送收集起来进行存储,并同时弹出实时弹幕进行展示!大概效果如截图所示:顶部横着漂浮的,就是一条测试弹幕。顶部是两个权限设置入口。下面是一个可滑动列表,用于展示已接收的历史通知消息。
界面和逻辑全部用compose和kotlin编写,看起来还行吧!
下面我逐步对项目进行说明,项目中对于kotlin和Compose的语法,我会挑一些比较陌生的进行讲解,代码边写边理解。
开干!
二、 大概思路
要完成这个APP,最核心的部分是一定要获取到系统通知。这块比较好说,继承NotificationListenerService类,就可以监听到系统的所有消息通知。接下来就是分几步了。
1、监听到消息后,解析其中我们需要的内容,进行保存。
2、把拿到的消息内容,渲染到APP界面上的列表中。
3、绘制一个展示弹幕的View,由于我们是需要在手机任意界面都可以看到实时消息弹幕,所以这个View就不能放在Activity里。这个View得做成悬浮窗的效果,得通过WindowManager来设置。
三、 项目基础配置
compose涉及的依赖比较多,如果人为配置还是很让人头疼。但是目前Android Studio支持一键创建compose+kotlin项目,所以如果是新建项目,到没有什么特别关注和难的地方。
补充几个项目里我额外用的依赖,部分依赖有版本号关联,不对的话会报错的,需要大家需要根据自己项目的配置,填写合适的版本号。
//kotlin
implementation 'androidx.core:core-ktx:1.9.22'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.2"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.2"
implementation "org.jetbrains.kotlin:kotlin-reflect"
implementation 'androidx.navigation:navigation-runtime-ktx:2.4.1'
implementation("androidx.navigation:navigation-compose:2.5.3")
//sqlite
implementation 'org.litepal.guolindev:core:3.1.1'
// compose Image Drawable
implementation "com.google.accompanist:accompanist-drawablepainter:0.28.0"
}
四、开始第一步,监听获取系统消息
创建完项目后,可以先别急着写Activity,我们先去吧系统通知获取的部分做了。
系统消息得从NotificationListenerService继承类,然后去实现onNotificationPosted方法,就可以拿到消息了。所以我们得创建一个Service,继承NotificationListenerService类。
具体逻辑可以看注释
class MyNotificationService : NotificationListenerService() {
private val TAG = "MyNotificationService"
override fun onCreate() {
super.onCreate()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(TAG, "Service is started" + "-----")
return super.onStartCommand(intent, flags, startId)
}
//重写消息监听的方法
override fun onNotificationPosted(sbn: StatusBarNotification?) {
//super.onNotificationPosted(sbn)
try {
//有些通知不能解析出TEXT内容,这里做个信息能判断
if (sbn!!.notification.tickerText != null) {
//转成自定义的Notifications对象
val notice = toNotice(sbn)
//协程
GlobalScope.launch(Dispatchers.IO) {
//数据库存储
saveNotice(notice)
}
}
} catch (e: Exception) {
Log.e(TAG, "保存失败:$e")
}
}
private fun toNotice(sbn: StatusBarNotification): Notifications {
val extras = sbn.notification.extras
// 获取接收消息的抬头
val notificationTitle = extras.getString(Notification.EXTRA_TITLE)
// 获取接收消息的内容
val notificationText = extras.getString(Notification.EXTRA_TEXT)
Log.e(TAG, "ticker" + sbn.notification.tickerText)
Log.e(TAG, "$notificationTitle $notificationText")
val simpleDateFormat = SimpleDateFormat("yyyyMMdd-HH:mm:ss")
val date = Date(sbn.notification.`when`)
val n = Notifications() \\Notifications类为自定义的消息Bean类
val time = simpleDateFormat.format(date)
n.time = time
n.visibility = sbn.notification.visibility.toString()
n.title = notificationTitle
n.ticker = notificationText
n.iconLevel = sbn.notification.iconLevel.toString()
n.creatorPackage = sbn.packageName
n.channelId = sbn.notification.channelId
n.category = sbn.notification?.category.toString()
n.isUpload = "0"
n.timeStamp = System.currentTimeMillis().toString()
return n
}
private suspend fun saveNotice(n: Notifications) {
n.save() \\数据保存,此为引入的郭神的litepal带来的方法
KLog.d(TAG, "消息保存成功")
}
override fun onDestroy() {
super.onDestroy()
}
MyNotificationService类全是由kotlin写的,以防有的小伙伴看不懂有些代码在干嘛,部分语法我拿出来在这里单独说明一下。
if (sbn!!.notification.tickerText != null) {
val notice = toNotice(sbn)
GlobalScope.launch(Dispatchers.IO) {
saveNotice(notice)
}
}
private suspend fun saveNotice
sbn!!.notification
if (sbn!!.notification.tickerText != null) {
...
}
“!!”表示,当变量如果为null时,会抛出空指针异常,不会执行后面的属性调用。
这个感觉不就是java的默认规则吗,java在变量为空的时候,调用属性那就是会报错空指针异常。kotlin为啥还要加“!!”,什么都不加,不也一样嘛?
其实这里这样处理,确实它的原因。主要还是因为kotlin是一个对空对象管理很严格的语言,所有的对象在声明的时候,你就要说明该对象是否可为空,是否可重新赋值。
而在这里,sbn这个对象并不是我们声明的,而是从onNotificationPosted方法里面入参带进来的。也就是说,kotlin认为,sbn此时是不可信的。
所以kotlin的语法,要求开发者一定要对这个对象进行null值校验处理。
而在这里,我是先假设,我无条件相信系统传来的sbn肯定是一个有值的对象,不可能为null。所以,为了避免为一个有值的对象,频繁做null判空,我就给加上了"!!"。
意思就是:“空指针就空指针把,反正我相信它不会为空”(因为我是相信NotificationListenerService不会给我null),大家在正常开发其他程序时不要模仿,对于传参处理该判空还是得判)
GlobalScope.launch()
GlobalScope.launch(Dispatchers.IO) {
...
}
这一行其实很简单,它是kotlin协程的一个启动语法,通过launch可以启动一个协程。
启动协程还有其他的语句,在这里就不做赘述了,大家可以百度都有。
协程可以普遍理解为是轻量化的线程(当然实际它不是线程)。
在对协程使用不深,比如只是调用一个异步任务的情况,那大家可以试着把协程就当做线程去使用。
但是协程总归不是线程,我说说我个人理解:协程是线程上的片段,一个线程可以创建运行多个协程。而一个协程也不只归属于某一个线程,它可以先后切换运行在多个线程之间。
所以如果大家对异步任务有着复杂的使用,比如有多个异步任务,任务之间还有先后顺序要求。那我建议,在彻底熟悉协程的任务机制前,此类场景不要轻易使用协程。
Dispatchers.IO
//指定调度器
GlobalScope.launch(Dispatchers.IO) {
...
}
//传参为空,则使用默认调度器Dispatchers.Default
GlobalScope.launch() {
...
}
我们说过一个协程不只归属于某一个线程里,它可以切换运行在多个线程之间。
这是协程调度器,来指定当前协程运行在哪个线程上。
在这里,通过使用GlobalScope.launch(),创建一个协程时,可选传的一个入参,叫做协程调度器。它的作用是确定让协程运行在哪个线程上。
需要注意的是,调度器不仅可以在创建协程的时候指定,也可以在协程运行中,进行指定切换。比如下面这个例子:
GlobalScope.launch(Dispatchers.MAIN) {
//使用MAIN调度器,在UI主线程中运行
Log.i(TAG, "此为UI线程")
withContext(Dispatchers.IO) {
//使用IO调度器,切换协程所在的线程,此刻代码在非UI线程运行
Log.i(TAG, "此为IO子线程")
}
//回到了主线程
Log.i(TAG, "此为UI线程")
}
GlobalScope.launch(),如果不传参数,launch()默认使用的调度器为Dispatchers.Default。这个调度器被设计用于运行 CPU 密集型操作,运算类任务可以使用它。
Dispatchers.IO调度器被设计为,适合读写I/O任务。因为我这个时候任务是将通知内容写入数据库,所以我选择了这个调度器。
还有Dispatchers.Main调度器,Main这个一看就是主线程相关,这确实就是主调度器。因为主线程是UI线程,如果使用不当,导致主线程被堵塞,那应用就卡顿,十分影响用户体验,所以想使用主调度器,一定要谨慎。
suspend
private suspend fun saveNotice(){
...
}
suspend是挂起函数的声明关键字。当一个函数需要在协程中调用的时候,那这个函数则必须被声明为挂起函数。
最后补充一下Notifications
class Notifications : LitePalSupport() { //继承LitePalSupport,继承litepal的sql方法
var id: Int? = null
var title: String? = null
var ticker: String? = null
var category: String? = null
var iconLevel: String? = null
var visibility: String? = null
var creatorPackage: String? = null
var time: String? = null
var channelId: String? = null
var isUpload: String? = null
var timeStamp: String? = null
var appName: String? = null
}
五、开始编写主页面
页面十分简单,能看到两个顶部菜单,一个占据大篇幅的列表,以及一个右下角悬浮的按钮。
两个顶部菜单是用于跳转设置页,让用户授权的。右下角的按钮,是用户控制弹幕开启或关闭的。
NoticeActivity (启动页)
class NoticeActivity : ComponentActivity() {
private lateinit var viewModel: MainViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//启动服务
val intent = Intent(this, MyNotificationService::class.java)
startService(intent)
setContent {
MyNoticeTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
tonalElevation = 5.dp
) {
Page()
}
}
}
//初始化viewModle,MainViewModel为自定义类
viewModel = ViewModelProvider(this)[MainViewModel::class.java]
//获取消息列表
viewModel.initNotis()
//liveData观察者注册,监听list变化,触发界面刷新
viewModel.nList.observe(this) { //观察监听list变化,触发界面刷新
if (it.size != 0) {
viewModel.notiList.clear()
viewModel.notiList = it as MutableList<Notifications>
}
}
}
//声明这个函数是个界面绘制方法
@Composable
fun Page(modifier: Modifier = Modifier) {
//Box,这是一个简单的布局组件,它可以将它内部的子组件堆叠在一起,可以覆盖或者并列显示
Box(modifier = modifier.fillMaxSize()) {
//Column 它能够将里面的子项按照从上到下的顺序垂直排列,类似于XML布局里面的竖向线性布局
Column {
//自定义界面方法
Menu()
//mutableStateListOf函数,用于创建一个可观察的状态对象,此时用于创建可观测的Notifications对象
//remember函数用于在Composable重触发绘制的时候,保持住当前值。
//组合remember和mutableStateListOf使用,被修饰的对象可以实现状态响应。能实现类似于Vue的数据绑定,达到响应式编程的效果。
viewModel.notiList = remember { mutableStateListOf<Notifications>() }
//自定义界面方法
NotiList()
}
//compose里面的FloatingActionButton,作用效果与xml里面的FloatingActionButton一致
LargeFloatingActionButton(
//点击事件
onClick = { },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(32.dp),
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
) {
//Button内部局部
}
}
}
@Composable
fun Menu(modifier: Modifier = Modifier) {
Surface(
shape = MaterialTheme.shapes.medium, shadowElevation = 5.dp,
modifier = Modifier.padding(all = 18.dp)
) {
Column(
modifier = Modifier.background(MaterialTheme.colorScheme.background)
) {
Row(
modifier = Modifier
.clickable {
//打开监听引用消息Notification access
val intent_s = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
startActivity(intent_s)
}
.padding(all = 15.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "通知设置",
modifier = Modifier
.padding(all = 1.dp)
.weight(1f),
style = MaterialTheme.typography.titleMedium,
maxLines = 1
)
Icon(
Icons.Default.ArrowForward,
contentDescription = "跳转进入",
modifier = Modifier
.padding(end = 4.dp)
)
}
Row(
modifier = Modifier
.clickable {
val intent_p =
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:com.liuzi.mynotice")
)
startActivity(intent_p)
}
}
.padding(all = 15.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "应用设置",
modifier = Modifier
.padding(all = 1.dp)
.weight(1f),
style = MaterialTheme.typography.titleMedium,
maxLines = 1
)
Icon(
Icons.Default.ArrowForward,
contentDescription = "跳转进入",
modifier = Modifier
.padding(end = 4.dp)
)
}
}
}
}
@Composable
fun NotiList() {
//竖向列表控件
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(18.dp)
) {
//列表子项遍历notilist,循环渲染自定义界面NotiCard
items(viewModel.notiList) { no ->
NotiCard(no) //
}
}
}
//循环列表的子项,具体每个通知的卡片
@Composable
fun NotiCard(no: Notifications) {
Spacer(modifier = Modifier.height(8.dp))
Surface(shape = MaterialTheme.shapes.medium, shadowElevation = 5.dp) {
//Row,横向线性布局
Row(
modifier = Modifier
//点击时间,点击打开对应APP
.clickable {
val intent =
packageManager.getLaunchIntentForPackage(no.creatorPackage)
intent?.let { startActivity(it) }
}
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(start = 10.dp, end = 8.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
//图片组件,用于显示对应通知消息的app图标
Image(
painter =
if (no.creatorPackage != null)
rememberDrawablePainter(
//获取app图标
drawable = applicationContext.getPackageManager()
.getApplicationIcon(no.creatorPackage)
)
else
painterResource(
id = R.drawable.notifi
),
contentDescription = null,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.border(1.5.dp, MaterialTheme.colorScheme.secondary, CircleShape),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = no.title ?: "未知",
modifier = Modifier.padding(all = 4.dp),
style = MaterialTheme.typography.bodyLarge,
maxLines = 1
)
Text(
text = no.ticker ?: "未知",
modifier = Modifier.padding(all = 4.dp),
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
MainViewModel
什么是ViewModel呢?这个其实并不是Compose引入的新机制,这是Android已经提供的一种开发机制。这与UI工具使用Compose还是XML无关,可能很多大佬在之前已经用过。
在初学Android开发的时候,我们会将一个活动的UI交互,UI渲染刷新,业务逻辑处理,数据请求处理等一系列逻辑,全部写在当前的Activity类中。
但理论上,这样做不符合“单一责任”原则。Activity类只应该负责UI交互,UI渲染刷新。其他的逻辑处理,数据请求应该单独存放和处理。
ViewModel类就是为此出现的,专门用于处理、和存放应用程序页面所需的数据,将Activity释放出来,只需要处理UI交互,UI渲染的工作。
将Activity拆分出ViewModel还有一个好处,那就是当应用遇到横竖屏切换,或者夜间模式切换的时候,Activity是会触发生命周期重启的,除非把数据使用savedInstanceState保存,否则生命周期重启的时候,用户未被保存的数据会被清空。
而在此时,ViewModel的生命周期,是不受Activity的生命周期影响。也就是说,当手机横竖屏切换时,虽然Activity重启了,但是由于数据所在的对象在ViewModel中,所以数据并不会丢失。
class MainViewModel : ViewModel() {
//创建LiveData对象
private var _notiList = MutableLiveData<List<Notifications>>()
private val TAG = "MainViewModel"
var notiList = mutableListOf<Notifications>()
val nList: LiveData<List<Notifications>>
get() = _notiList
init {
//viewmodel对象创建初始化时,执行init
}
//提供从数据库获取通知数据的方法
fun initNotis() {
//调用litepal方法,获取最新的1000条通知消息
val notisList = LitePal.order("time desc").limit(1000).find<Notifications>()
//postValue用于更新数据内容
_notiList.postValue(notisList)
}
}
这里大概内容大家应能理解,比较陌生的,应该只有是修饰List的LiveData。
LiveData 是Jetpack的一个组件,是一个可被观察的数据存储器类, 具有感知组件生命周期的能力。当 LiveData 保存的数据发生变化时就会通知观察者,观察者接收到通知后可以进行 UI 数据刷新或者其他操作。
我们在Activity中,就注册了对_notiList对象的观察。所以ViewModel这里的数据更新,会被Acitivty获取到,开展UI刷新的操作。
六、总结
本次我们完成了两个部分的内容:
1、创建了一个service,继承NotificationListenerService,获得到了系统通知的数据。
2、使用Compose绘制了主页,能展示通知记录。其中使用了ViewModel和LiveData,将数据保存和业务逻辑与UI交互进行了拆分。
项目使用NotificationListenerService,还需要做一些配置,我在这里说明一下。
在AndroidMenifest中,对service的注册内容,补充声明permission和filter。
<service
android:name=".service.MyNotificationService"
android:exported="true"
android:label="通知监控"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
还有就是,记得我们Activity的UI里面,放了两个菜单还记得吗。程序运行后,记得分别点一下,进入系统设置里,授权一下程序的通知权限。
OK,程序应该可以运行了,数据都可以拿到了并保存在本地sqlite了,那下一篇我们开始把通知做成实时弹幕!