Android 音乐播放器及RemoteView实践

Android 音乐播放器及RemoteView实践

前言

上一篇文章《Android前台服务及通知使用总结》把前台服务和通知的内容简单介绍了下,算是这篇文章的前提吧,这篇文章我想通过一个音乐播放器,整合下前台服务、自定义通知、桌面小组件,希望能有所收获。

需求分析

做之前还是简单分析下我要做的东西吧,简单列一下:

  1. 编写工具类,获取所有音频文件(uri),在页面列表显示
  2. 启动前台服务,自定义通知栏通知
  3. 编写音频播放器,简单及控制播放音乐
  4. 页面列表中,点击播放音乐,通知栏更新显示数据
  5. 通知栏通知能播放、暂停、上一首、下一首音乐
  6. 桌面小组件和通知栏通知功能一致

东西不难,工作量还挺多的,摸鱼也写了很久,下面慢慢介绍吧,实在不行分两篇文章。

Audio数据获取

要做一个音乐播放器,首先就是要拿到数据嘛,一般都是扫描手机内的音频,通过MediaStore去查询。这个查询是同步的,所以我们还要做下异步操作。

下面我就写下查询Audio的方法,异步操作之类的,我放github文件链接,有兴趣的读者再看吧:

kotlin 复制代码
/**
 * 使用ContentResolver获取所有音频文件(耗时操作)
 *
 * @param context 上下文
 * @param path 在Music路径下的相对路径,不填全局搜索
 * @return 所有音频文件列表
 */
fun loadAudiosByMediaStore(context: Context, path: String? = null): List<Audio> {
    val result: MutableList<Audio> = ArrayList()

    // 查询音频的uri
    val uri: Uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI

    // 查询条目
    val projection = arrayOf(
        MediaStore.Audio.Media._ID,
        MediaStore.Audio.Media.TITLE,
        MediaStore.Audio.Media.ARTIST,
        MediaStore.Audio.Media.ALBUM,
        MediaStore.Audio.Media.DURATION,
        MediaStore.Audio.Media.SIZE
    )

    // 查询条件
    var selection = "${MediaStore.Audio.Media.IS_MUSIC} != 0"
    var selectionArg = ""

    // 是否增加路径
    path?.let {
        // Android 10,路径保存在RELATIVE_PATH
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            selection += " AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} like ?"
            selectionArg += "%" + Environment.DIRECTORY_MUSIC + File.separator + it + "%"
        } else {
            val dstPath = StringBuilder().let { sb ->
                sb.append(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC)!!.path)
                sb.append(File.separator)
                sb.append(it)
                sb.toString()
            }
            selection += " AND ${MediaStore.Video.Media.DATA} like ?"
            selectionArg += "%$dstPath%"
        }
    }


    // 排序
    val sortOrder = "${MediaStore.Audio.Media.TITLE} ASC"

    // 查询
    val cursor = context.contentResolver.query(
        uri,
        projection,
        selection,
        if (selectionArg.isNotEmpty()) arrayOf(selectionArg) else null,
        sortOrder
    )

    // 处理结果
    cursor?.let {
        while (it.moveToNext()) {
            try {
                val id = it.getLong(it.getColumnIndexOrThrow(MediaStore.Audio.Media._ID))
                result.add(
                    Audio(
                    id,
                    it.getString(it.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)),
                    it.getString(it.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)),
                    it.getString(it.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM)),
                    it.getLong(it.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)),
                    ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id),
                    it.getLong(it.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE))
                )
                )
            }catch (e: Exception) {
                e.printStackTrace()
            }
        }
    }

    cursor?.close()
    return result
}

对应Audio数据类的格式如下:

kotlin 复制代码
/**
 * 音频数据类
 */
@Parcelize
data class Audio(
    val id: Long,
    val title: String,
    val artist: String,
    val album: String,
    val duration: Long,
    val uri: Uri,
    val size: Long
) : Parcelable

上面的查询是同步的,我们可以通过协程、线程池、AsyncTask把它转成异步,也可以Loader机制去异步加载,可以参考我写好的工具类:

AudioLoader

启动前台服务

上一篇文章已经有介绍了,我们就从前台应用去启动前台服务:

kotlin 复制代码
fun startForegroundServiceFromForeground() {
    val intent = Intent(context, AudioService::class.java)
    if (Build.VERSION.SDK_INT >= 26) {
        // Android8 必须通过startForegroundService开启前台服务,Android9 需要添加权限
        context.startForegroundService(intent)
    }else {
        context.startService(intent)
    }
}

AudioService代码如下,里面加载了下数据,立马创建前台通知,将服务变为前台服务:

kotlin 复制代码
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.util.Log
import androidx.lifecycle.LifecycleService
import com.silencefly96.module_base.utils.NotificationUtil.Companion.NOTIFICATION_ID
import com.silencefly96.module_tech.service.audio_player.audio.Audio
import com.silencefly96.module_tech.service.audio_player.audio.AudioLoader
import com.silencefly96.module_tech.service.audio_player.player.AudioPlayerManager

class AudioService: LifecycleService() {

    // 音乐播放器
    private val mAudioPlayerManager by lazy {
        AudioPlayerManager(this@AudioService)
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        super.onStartCommand(intent, flags, startId)
        // 使用协程查找音频
        AudioLoader.loadAudioByMediaStoreCoroutines(
            this,
            object : AudioLoader.OnAudioPrepared {
                override fun onAudioPrepared(result: List<Audio>?) {
                    result?.let {
                        // 获取播放列表,然后播放第一首
                        mAudioPlayerManager.init(it)
                        if (it.isNotEmpty()) {
                            mAudioPlayerManager.play(0)
                        }
                    }
                }
            },
            lifecycle)

        // 创建通知启动前台服务(要在5s内)
        val notification = mAudioPlayerManager.createNotification()
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            // 安卓10要添加一个参数,在manifest中配置
            startForeground(
                NOTIFICATION_ID,
                notification,
                ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST
            )
        } else {
            startForeground(NOTIFICATION_ID, notification)
        }

        return START_STICKY
    }

    override fun onDestroy() {
        Log.d("TAG", "AudioService onDestroy: ")
        mAudioPlayerManager.onDestroy()
        super.onDestroy()
    }
}

这里用的LifecycleService,能够通过它的lifecycle启动协程。音乐播放器的管理类,我也在这里创建了,由它来控制音乐播放等操作,下面来看。

创建音频播放器

AudioService中加载完数据,把数据全部传给了AudioPlayerManager,并将它初始化:

kotlin 复制代码
// 音乐播放器
private val mAudioPlayer: AudioPlayer = AudioPlayer(context)

public fun init(audios: List<Audio>) {
    mAudios.addAll(audios)

    // 启动广播接收器
    registerReceiver()
}

registerReceiver我们后面会讲到,这里只是把数据放到了AudioPlayerManager里面.

而音乐播放器就在AudioPlayerManager里面初始化的时候就创建了:

kotlin 复制代码
import android.content.Context
import android.media.MediaPlayer
import android.net.Uri

/**
 * 简单的音乐播放器
 *
 * @author fdk
 * @date 2024-04-08
 */
class AudioPlayer(private val mContext: Context) {
    // 当前音乐
    private var curUri: Uri? = null

    private val mediaPlayer: MediaPlayer = MediaPlayer().apply {
        // 单曲循环
        setOnCompletionListener {
            curUri?.let {
                start(curUri!!)
            }
        }
    }

    /**
     * 播放音乐
     *
     * @param uri 音乐的uri
     */
    fun start(uri: Uri) {
        curUri = uri
        mediaPlayer.reset()
        mediaPlayer.setDataSource(mContext, uri)
        mediaPlayer.prepareAsync()
        mediaPlayer.setOnPreparedListener {
            mediaPlayer.start()
        }
    }

    /**
     * 继续播放音乐
     */
    fun play() {
        if (!mediaPlayer.isPlaying) {
            mediaPlayer.start()
        }
    }

    /**
     * 暂停播放音乐
     */
    fun pause() {
        if (mediaPlayer.isPlaying) {
            mediaPlayer.pause()
        }
    }

    /**
     * 停止播放音乐
     */
    fun stop() {
        if (mediaPlayer.isPlaying || mediaPlayer.currentPosition > 0) {
            mediaPlayer.stop()
            mediaPlayer.prepareAsync()
        }
    }

    /**
     * 释放音乐播放器资源
     */
    fun release() {
        mediaPlayer.release()
    }
}

音乐播放器写的比较简单,还有很多功能写在AudioPlayerManager里面,因为和remoteView有关,这里先不讲吧。

自定义通知

还记得AudioService里面的前台通知吗?实际是在AudioPlayerManager里面创建的:

kotlin 复制代码
// 创建通知启动前台服务(要在5s内)
val notification = mAudioPlayerManager.createNotification()

创建通知

这里面就包含我们的自定义通知,使用了remoteView:

kotlin 复制代码
/**
 * 创建一个默认的通知,不发送,由service发送
 */
public fun createNotification(): Notification {
    val intent = Intent(context, MainActivity::class.java).apply {
        addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
    }
    // 创建一个pendingIntent,点击整个通知跳转APP
    val pendingIntent = PendingIntent.getActivity(
        context,
        REQUEST_CODE_ACTIVITY,
        intent,
        PendingIntent.FLAG_IMMUTABLE
    )

    // 通过builder,把声音和震动关了
    mNotificationManager.mNotificationBuilder.apply {
        setSound(null)
        setVibrate(null)
        setDefaults(0)
    }
    // importance也会影响声音和震动
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        mNotificationManager.mChannel.apply {
            importance = NotificationManager.IMPORTANCE_LOW
            mNotificationManager.updateChannel(this)
        }
    }

    // 创建通知管理器
    return mNotificationManager.createCustomNotification(
        getRemoteView(null),
        pendingIntent
    )
}

private fun updateNotification(audio: Audio) {
    // 更新remoteView到通知栏
    mNotificationManager.sendOrUpdateCustomNotification(getRemoteView(audio))
}

创建RemoteView

创建通知和更新通知,在上一篇文章讲了,我们着重看下remoteView的使用:

kotlin 复制代码
private fun getRemoteView(audio: Audio?): RemoteViews {
    val remoteViews = RemoteViews(context.packageName, R.layout.item_remote_notification)

    // 设置标题
    val title = audio?.title ?: "waiting for play music..."
    remoteViews.setTextViewText(R.id.title, title)

    // 设置播放按钮
    val text = if (isPlaying) "PAUSE" else "PALY"
    remoteViews.setTextViewText(R.id.pause, text)

    // 上一首
    val lastIntent = Intent(ACTION_LAST)
    val lastPendingIntent = PendingIntent.getBroadcast(
        context, REQUEST_CODE_CLICK_LAST, lastIntent, PendingIntent.FLAG_IMMUTABLE
    )
    remoteViews.setOnClickPendingIntent(R.id.last, lastPendingIntent)

    // 暂停
    val pauseIntent = Intent(ACTION_PAUSE)
    val pausePendingIntent = PendingIntent.getBroadcast(
        context, REQUEST_CODE_CLICK_PAUSE, pauseIntent, PendingIntent.FLAG_IMMUTABLE
    )
    remoteViews.setOnClickPendingIntent(R.id.pause, pausePendingIntent)

    // 下一首
    val nextIntent = Intent(ACTION_NEXT)
    val nextPendingIntent = PendingIntent.getBroadcast(
        context, REQUEST_CODE_CLICK_NEXT, nextIntent, PendingIntent.FLAG_IMMUTABLE
    )
    remoteViews.setOnClickPendingIntent(R.id.next, nextPendingIntent)

    return remoteViews
}

item_remote_notification页面效果如下:

前面我们给通知设置的pendingIntent是点击通知跳转到activity的,我们要给remoteViews上的按钮也加上事件,也需要用到pendingIntent,这里我们选的是发送Broadcast的pendingIntent。

接收自定义通知点击事件

我们给自定义通知的按钮设置好发送Broadcast的pendingIntent后,我们就应该去接收这个事件,需要用到receiver:

kotlin 复制代码
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.silencefly96.module_tech.service.audio_player.player.AudioPlayerManager
import com.silencefly96.module_tech.service.audio_player.audio.Audio

/**
 * 动态广播,接受通知栏动作
 */
class AudioActionReceiver(private val audioPlayerManager: AudioPlayerManager): BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        audioPlayerManager.apply {
            when(intent.action) {
                AudioPlayerManager.ACTION_LAST -> last()
                AudioPlayerManager.ACTION_PAUSE -> playOrPause()
                AudioPlayerManager.ACTION_NEXT -> next()
                AudioPlayerManager.ACTION_PLAY -> {
                    val position = intent.getIntExtra("position", 0)
                    val audio = intent.getParcelableExtra<Audio>("audio")

                    Log.d("TAG", "onReceive: position=$position, audio=$audio")
                    // 两种播放方法
                    if (audio == null) {
                        play(position)
                    }else {
                        play(audio)
                    }
                }
            }
        }
    }
}

AudioActionReceiver持有了AudioPlayerManager的引用,通过AudioPlayerManager去控制音频播放,前面AudioPlayerManager在init的时候我们就注册了动态广播:

kotlin 复制代码
private fun registerReceiver() {
    val intentFilter = IntentFilter().apply {
        addAction(ACTION_LAST)
        addAction(ACTION_PAUSE)
        addAction(ACTION_NEXT)
        addAction(ACTION_PLAY)
    }
    mActionReceiver = AudioActionReceiver(this)
    context.registerReceiver(mActionReceiver, intentFilter)
}

也就是说remoteView中按钮的点击事件,会通过pendingIntent,通过广播发送出去,然后到达AudioActionReceiver,而AudioActionReceiver又持有了AudioPlayerManager,于是就能控制音频的播放了。

这里是我们需求点的第五步,而第四步也需要用到AudioActionReceiver。

markdown 复制代码
4. 页面列表中,点击播放音乐,通知栏更新显示数据
5. 通知栏通知能播放、暂停、上一首、下一首音乐

实现点击播放

因为我们的AudioPlayerManager是在AudioService里面的,所以在页面列表里面是没法控制的,我们只能通过AudioActionReceiver去控制:

kotlin 复制代码
val intent = Intent(AudioPlayerManager.ACTION_PLAY)
intent.putExtra("position", position)
intent.putExtra("audio", itemObj)
requireContext().sendBroadcast(intent)

AudioActionReceiver收到播放的intent之后,就会通过AudioPlayerManager的play去播放

kotlin 复制代码
/**
 * 播放列表音乐
 *
 * @param index 列表内索引
 */
public fun play(index: Int) {
    // 列表循环
    mCurPosition = (index + mAudios.size) % mAudios.size
    // 更新通知栏
    updateNotification(mAudios[mCurPosition])
    // 更新桌面小组件
    updateAppWidget(mAudios[mCurPosition])
    // 播放音乐
    mAudioPlayer.start(mAudios[mCurPosition].uri)
    isPlaying = true
    Log.d("TAG", "play: $mCurPosition")
}

而这时候,AudioPlayerManager会重新创建通知,去更新通知栏的自定义通知:

kotlin 复制代码
private fun updateNotification(audio: Audio) {
    // 更新remoteView到通知栏
    mNotificationManager.sendOrUpdateCustomNotification(getRemoteView(audio))
}

桌面小组件

上面写的有点乱,但是音乐播放功能,以及通知栏对音乐播放的控制实际已经完成了。

这里,我们用和通知栏一样布局的remoteView做一个桌面小组件,也来实现对APP音频的控制,这个内容相对独立,讲起来应该不会那么乱。

创建小组件

首先我们继承AppWidgetProvider,实现一个小组件,onUpdate里面对小组件进行更新,设置好remoteView,还是类似的代码:

kotlin 复制代码
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.util.Log
import android.widget.RemoteViews
import com.silencefly96.module_tech.R
import com.silencefly96.module_tech.service.audio_player.audio.Audio
import com.silencefly96.module_tech.service.audio_player.player.AudioPlayerManager


/**
 * Implementation of App Widget functionality.
 */
class AudioWidget : AppWidgetProvider() {
    // 当前音乐信息
    private var mAudio: Audio? = null

    // 当前音乐位置
    private var mPosition = 0

    // 音乐播放状态
    private var isPlaying = false

    override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray
    ) {
        // There may be multiple widgets active, so update all of them
        for (appWidgetId in appWidgetIds) {
            val views = getRemoteView(context, mAudio)
            appWidgetManager.updateAppWidget(appWidgetId, views)
        }
    }

    private fun getRemoteView(context: Context, audio: Audio?): RemoteViews {
        val remoteViews = RemoteViews(context.packageName, R.layout.item_remote_notification)

        // 设置标题
        val title = audio?.title ?: "waiting for play music..."
        remoteViews.setTextViewText(R.id.title, title)

        // 设置播放按钮
        val text = if (isPlaying) "PAUSE" else "PALY"
        remoteViews.setTextViewText(R.id.pause, text)

        // 上一首
        val lastIntent = Intent(AudioPlayerManager.ACTION_LAST)
        val lastPendingIntent = PendingIntent.getBroadcast(
            context, AudioPlayerManager.REQUEST_CODE_CLICK_LAST, lastIntent, PendingIntent.FLAG_IMMUTABLE
        )
        remoteViews.setOnClickPendingIntent(R.id.last, lastPendingIntent)

        // 暂停
        val pauseIntent = Intent(AudioPlayerManager.ACTION_PAUSE)
        val pausePendingIntent = PendingIntent.getBroadcast(
            context, AudioPlayerManager.REQUEST_CODE_CLICK_PAUSE, pauseIntent, PendingIntent.FLAG_IMMUTABLE
        )
        remoteViews.setOnClickPendingIntent(R.id.pause, pausePendingIntent)

        // 下一首
        val nextIntent = Intent(AudioPlayerManager.ACTION_NEXT)
        val nextPendingIntent = PendingIntent.getBroadcast(
            context, AudioPlayerManager.REQUEST_CODE_CLICK_NEXT, nextIntent, PendingIntent.FLAG_IMMUTABLE
        )
        remoteViews.setOnClickPendingIntent(R.id.next, nextPendingIntent)

        return remoteViews
    }

    override fun onReceive(context: Context, intent: Intent) {
        super.onReceive(context, intent)
        if(intent.action.equals(AudioPlayerManager.ACTION_UPDATE)) {
            isPlaying = intent.getBooleanExtra("state", false)
            mPosition = intent.getIntExtra("position", 0)
            mAudio = intent.getParcelableExtra("audio")

            // 更新小部件
            val appWidgetManager = AppWidgetManager.getInstance(context)
            val componentName = ComponentName(context, AudioWidget::class.java)
            appWidgetManager.updateAppWidget(componentName, getRemoteView(context, mAudio))

            Log.d("TAG", "AudioWidget onReceive: isPlaying=$isPlaying position=$mPosition, audio=$mAudio")
        }
    }
}

重点就在onReceive方法里面,说白了AppWidgetProvider实际就是一个receiver,我们要更新它,只要用它的onReceive接收广播就行了。

我们给AppWidgetProvider发送要更新的广播,重新创建一个remoteView给它更新下就OK了。

注册小组件

既然AppWidgetProvider是一个receiver,那么肯定要注册啊,manifest里面注册:

xml 复制代码
<receiver
    android:name=".service.audio_player.app_widget.AudioWidget"
    android:exported="false">
    <intent-filter>
        <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
        <!--自定义播放器action,用于更新remoteView-->
        <action android:name="com.silencefly96.module_tech.tech.remote_view.update" />
    </intent-filter>

    <meta-data
        android:name="android.appwidget.provider"
        android:resource="@xml/audio_widget_info" />
</receiver>

注意下intent-filter里面加上更新的ACTION,以及我们要让AudioWidget更换remoteView的ACTION。

这里还要配置一份audio_widget_info.xml,用来描述小组件的信息:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:minWidth="150dp"
    android:minHeight="50dp"
    android:updatePeriodMillis="86400000"
    android:previewImage="@mipmap/ic_launcher"
    android:initialLayout="@layout/item_remote_notification"
    android:resizeMode="horizontal|vertical"
    android:widgetCategory="home_screen">
</appwidget-provider>
<!--
代码配置说明如下:

    1、minWidth和minHeight属性指定默认情况下App Widget占用的最小尺寸(为了使您App的小组件适配多种屏幕,
    此处的最小尺寸不得大于4 x 4单元)。更多信息请参见应用程序小组件设计指南。

    在Android12以上,可以通过targetCellWidth和targetCellHeight设置单元格数量

    2、updatePeriodMillis属性定义App Widget框架通过调用onUpdate()回调方法从AppWidgetProvider请求更新的频率。
    使用该值不能保证实际的更新会准时进行,我们不建议频繁地更新(每小时不超过一次),以便节省电池电量。

    3、initialLayout属性指向定义App Widget布局的布局资源。

    4、configure属性(可选属性)定义了用户添加App Widget时要启动的活动(Activity),以便配置App Widget属性。

    5、previewImage属性指定配置后的应用小组件的预览,用户在选择应用小组件时可见。如果未提供,用户看到的为您应用程序的
    启动器图标。

    6、resizeMode属性指定可以调整窗口小组件大小的规则,例如水平、垂直或双向调整大小等。

    7、minResizeHeight和minResizeWidth属性指定窗口小组件可以调整大小的最小高度与最小宽度(单位为dp)。
-->

代码注释里面讲的很清楚了,这里有点坑的就是只有Android12后才能按单元格给小组件设置宽高,难怪没人用。。。

更新小组件

要更新小组件,我们只要用AudioWidget在intent-filter设置好的更新action就行了:

kotlin 复制代码
private fun updateAppWidget(audio: Audio) {
    val intent = Intent(ACTION_UPDATE)
    intent.component = ComponentName(context, AudioWidget::class.java)
    intent.putExtra("state", isPlaying)
    intent.putExtra("position", mCurPosition)
    intent.putExtra("audio", audio)
    context.sendBroadcast(intent)
}

比如在AudioPlayerManager播放或暂停音乐里面:

kotlin 复制代码
/**
 * 播放或暂停音乐
 */
public fun playOrPause() {
    // 暂停或者重新播放音乐
    if (isPlaying) {
        mAudioPlayer.pause()
    }else {
        mAudioPlayer.play()
    }
    isPlaying = !isPlaying
    // 更新通知栏
    updateNotification(mAudios[mCurPosition])
    // 更新桌面小组件
    updateAppWidget(mAudios[mCurPosition])
    Log.d("TAG", "pause: $isPlaying")
}

还有个问题,因为我们对播放器的控制都是通过发广播形式实现的,所以通知栏的remoteView和桌面小组件的remoteView展示的内容是同步更新的,这就很nice!

效果图

UI设计有点丑了,功能还是很好使的:

源码及Demo

源码涉及的文件比较多,不过我都放在了这个目录下,有兴趣可以参考下:

audio_player

Demo则是在这:

AudioPlayerTestDemo

小结

简单写了个音频播放器,把Uri音频查询、前台服务、自定义通知(RemoteView)、pengdingIntent、播放器、桌面小组件实践了下。

相关推荐
你过来啊你2 小时前
Android Handler机制与底层原理详解
android·handler
RichardLai882 小时前
Kotlin Flow:构建响应式流的现代 Kotlin 之道
android·前端·kotlin
AirDroid_cn2 小时前
iQOO手机怎样相互远程控制?其他手机可以远程控制iQOO吗?
android·智能手机·iphone·远程控制·远程控制手机·手机远程控制手机
YoungHong19923 小时前
如何在 Android Framework层面控制高通(Qualcomm)芯片的 CPU 和 GPU。
android·cpu·gpu·芯片·高通
xzkyd outpaper3 小时前
Android 事件分发机制深度解析
android·计算机八股
努力学习的小廉3 小时前
深入了解linux系统—— System V之消息队列和信号量
android·linux·开发语言
程序员江同学4 小时前
Kotlin/Native 编译流程浅析
android·kotlin
移动开发者1号5 小时前
Kotlin协程与响应式编程深度对比
android·kotlin
花花鱼14 小时前
android studio 设置让开发更加的方便,比如可以查看变量的类型,参数的名称等等
android·ide·android studio