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、播放器、桌面小组件实践了下。

相关推荐
千里马学框架14 小时前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台14 小时前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone14 小时前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc15 小时前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo17 小时前
Kotlin 协程中的 Job 结构化并发与取消
android
sun00770017 小时前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼18 小时前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone19 小时前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen19 小时前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone19 小时前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui