2022年都快结束了,还有人不会安卓录屏?在安卓上录制屏幕的的实现方式
前言
在我之前的文章 《以不同的形式在安卓中创建GIF动图》 中,我挖了一个坑,可以通过录制屏幕后转为 GIF 的方式来创建 GIF。只是当时我只是提了这么一个思路,并没有给出录屏的方式,所以本文的内容就是教大家如何通过调用系统 API 的方式录制屏幕。
开始实现
技术原理
在安卓 5.0 之前,我们是无法通过常规的方式来录制屏幕或者截图的,要么只能 ROOT,要么就是只能用一些很 Hack 的方式来实现。
不过在安卓 5.0 后,安卓开放了 MediaProjectionManager
、 VirtualDisplay
等 API,使得普通应用录屏成为了可能。
简单来说,录屏的流程如下:
- 拿到
MediaProjectionManager
对象 - 通过
MediaProjectionManager.createScreenCaptureIntent()
拿到请求权限的Intent
,然后用这个Intent
去请求权限并拿到一个权限许可令牌(resultData,本质上还是个 Intent)。 - 通过拿到的 resultData 创建
VirtualDisplay
投影。 VirtualDisplay
将图像数据渲染至Surface
中,最终,我们可以将Surface
的数据流写入并编码至视频文件。(Surface
可以由MediaCodec
创建,而MediaMuxer
可以将MediaCodec
的数据编码至视频文件中)
从上面的流程可以看出,其实核心思想就是通过 VirtualDisplay
拿到当前屏幕的数据,然后绕一圈将这个数据写入视频文件中。
而 VirtualDisplay
顾名思义,其实是用来做虚拟屏幕或者说投影的,但是这里并不妨碍我们通过它来录屏啊。
不过由于我们是通过虚拟屏幕来实现录屏的,所以如果应用声明了禁止投屏或使用虚拟屏幕,那么我们录制的内容将是空白的(黑屏)。
准备工作
明白了实现原理之后,我们需要来做点准备工作。
首先是做好界面布局,在主入口编写布局:
override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContent {val context = LocalContext.currentScreenRecordTheme {// A surface container using the 'background' color from the themeSurface(modifier = Modifier.fillMaxSize(),color = MaterialTheme.colors.background) {Column(modifier = Modifier.fillMaxSize(),verticalArrangement = Arrangement.Center,horizontalAlignment = Alignment.CenterHorizontally) {Button(onClick = {startServer(context)}) {Text(text = "启动")}}}}}
}
布局很简单,就是居中显示一个启动按钮,点击按钮后启动录屏服务(Server),这里因为我们的需求是需要录制所有应用界面,而非本APP的界面,所以需要使用一个前台服务并显示一个悬浮按钮用于控制录屏开始与结束。
所以我们需要添加悬浮窗权限,并动态申请:
添加权限: <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
检查并申请权限:
if (Settings.canDrawOverlays(context)) {// ……// 已有权限
}
else {// 跳转到系统设置手动授予权限(这里其实可以直接跳转到当前 APP 的设置页面,但是不同的定制 ROM 设置页面路径不一样,需要适配,所以我们直接跳转到系统通用设置让用户自己找去)Toast.makeText(context, "请授予“显示在其他应用上层”权限后重试", Toast.LENGTH_LONG).show()val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,Uri.parse("package:${context.packageName}"))context.startActivity(intent)
}
悬浮界面权限拿到后就是申请投屏权限。
首先,定义 Activity Result Api,并在获取到权限后将 ResultData 传入 Server,最后启动 Server:
private lateinit var requestMediaProjectionLauncher: ActivityResultLauncher<Intent>override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)// ……requestMediaProjectionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {if (it.resultCode == Activity.RESULT_OK && it.data != null) {OverlayService.setData(it.data!!)startService(Intent(this, OverlayService::class.java))}else {Toast.makeText(this, "未授予权限", Toast.LENGTH_SHORT).show()}}
}
然后,在按钮的点击回调中启动这个 Launcher:
val mediaProjectionManager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
requestMediaProjectionLauncher.launch(mediaProjectionManager.createScreenCaptureIntent()
)
在这里我们通过 getSystemService
方法拿到了 MediaProjectionManager
,并通过 mediaProjectionManager.createScreenCaptureIntent()
拿到请求权限的 Intent。
最终在授予权限后启动录屏 Server。
但是,这里有一点需要特别注意,由于安卓系统限制,我们必须使用前台 Server 才能投屏,并且还需要为这个前台 Server 显式设置一个通知用于指示 Server 正在运行中,否则将会抛出异常。
所以,添加前台服务权限:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
然后在我们的录屏服务中声明前台服务类型:
<serviceandroid:name=".overlay.OverlayService"android:enabled="true"android:exported="false"android:foregroundServiceType="mediaProjection" />
最后,我们需要为这个服务绑定并显示一个通知:
private fun initRunningTipNotification() {val builder = Notification.Builder(this, "running")builder.setContentText("录屏运行中").setSmallIcon(R.drawable.ic_launcher_foreground)val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManagerval channel = NotificationChannel("running","显示录屏状态",NotificationManager.IMPORTANCE_DEFAULT)notificationManager.createNotificationChannel(channel)builder.setChannelId("running")startForeground(100, builder.build())
}
需要注意的是,这里我们为了方便讲解,直接将创建和显示通知都放到了点击悬浮按钮后,并且停止录屏后也没有销毁通知。
各位在使用的时候需要根据自己需求改一下。
自此,准备工作完成。
哦,对了,关于如何使用 Compose 显示悬浮界面,因为不是本文重点,而且我也是直接套大佬的模板,所以这里就不做讲解了,感兴趣的可以自己看源码。
下面开始讲解如何录屏。
开始录屏
首先,我们编写了一个简单的帮助类 ScreenRecorder
:
class ScreenRecorder(private var width: Int,private var height: Int,private val frameRate: Int,private val dpi: Int,private val mediaProjection: MediaProjection?,private val savePath: String
) {private var encoder: MediaCodec? = nullprivate var surface: Surface? = nullprivate var muxer: MediaMuxer? = nullprivate var muxerStarted = falseprivate var videoTrackIndex = -1private val bufferInfo = MediaCodec.BufferInfo()private var virtualDisplay: VirtualDisplay? = nullprivate var isStop = false/*** 停止录制* */fun stop() {isStop = true}/*** 开始录制* */fun start() {try {prepareEncoder()muxer = MediaMuxer(savePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)virtualDisplay = mediaProjection!!.createVirtualDisplay("$TAG-display",width,height,dpi,DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC,surface,null,null)recordVirtualDisplay()} finally {release()}}private fun recordVirtualDisplay() {while (!isStop) {val index = encoder!!.dequeueOutputBuffer(bufferInfo, TIMEOUT_US.toLong())if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {resetOutputFormat()} else if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {//Log.d(TAG, "retrieving buffers time out!");//delay(10)} else if (index >= 0) {check(muxerStarted) { "MediaMuxer dose not call addTrack(format) " }encodeToVideoTrack(index)encoder!!.releaseOutputBuffer(index, false)}}}private fun encodeToVideoTrack(index: Int) {var encodedData = encoder!!.getOutputBuffer(index)if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {bufferInfo.size = 0}if (bufferInfo.size == 0) {encodedData = null}if (encodedData != null) {encodedData.position(bufferInfo.offset)encodedData.limit(bufferInfo.offset + bufferInfo.size)muxer!!.writeSampleData(videoTrackIndex, encodedData, bufferInfo)}}private fun resetOutputFormat() {check(!muxerStarted) { "output format already changed!" }val newFormat = encoder!!.outputFormatvideoTrackIndex = muxer!!.addTrack(newFormat)muxer!!.start()muxerStarted = true}private fun prepareEncoder() {val format = MediaFormat.createVideoFormat(MIME_TYPE, width, height)format.setInteger(MediaFormat.KEY_COLOR_FORMAT,MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)format.setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, IFRAME_INTERVAL)encoder = MediaCodec.createEncoderByType(MIME_TYPE)encoder!!.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)surface = encoder!!.createInputSurface()encoder!!.start()}private fun release() {if (encoder != null) {encoder!!.stop()encoder!!.release()encoder = null}if (virtualDisplay != null) {virtualDisplay!!.release()}mediaProjection?.stop()if (muxer != null) {muxer?.stop()muxer?.release()muxer = null}}companion object {private const val TAG = "el, In ScreenRecorder"private const val MIME_TYPE = "video/avc" // H.264 Advanced Video Codingprivate const val IFRAME_INTERVAL = 10 // 10 seconds between I-framesprivate const val BIT_RATE = 6000000private const val TIMEOUT_US = 10000}
}
在这个类中,接收以下构造参数:
- width: Int, 创建虚拟屏幕以及写入的视频宽度
- height: Int, 创建虚拟屏幕以及写入的视频高度
- frameRate: Int, 写入的视频帧率
- dpi: Int, 创建虚拟屏幕的 DPI
- mediaProjection: MediaProjection?, 用于创建虚拟屏幕的 mediaProjection
- savePath: String, 写入的视频文件路径
我们可以通过调用 start()
方法开始录屏;调用 stop()
方法停止录屏。
调用 start()
后,会首先调用 prepareEncoder()
方法。该方法主要用途是按照给定参数创建 MediaCodec
,并通过 encoder!!.createInputSurface()
创建一个 Surface
以供后续接收虚拟屏幕的图像数据。
预先设置完成后,按照给定路径创建 MediaMuxer
;将参数和之前创建的 surface
传入,创建一个新的虚拟屏幕,并开始接受图像数据。
最后,循环从上面创建的 MediaCodec
中逐帧读出有效图像数据并写入 MediaMuxer
中,即写入视频文件中。
看起来可能比较绕,但是理清楚之后还是非常简单的。
接下来就是如何去调用这个帮助类。
在调用之前,我们需要预先准备好需要的参数:
val savePath = File(externalCacheDir, "${System.currentTimeMillis()}.mp4").absolutePath
val screenSize = getScreenSize()
val mediaProjection = getMediaProjection()
savePath
表示写入的视频文件路径,这里我偷懒直接写成了 APP 的缓存目录,如果想要导出到其他地方,记得处理好运行时权限。screenSize
表示的是当前设备的屏幕尺寸mediaProjection
表示请求权限后获取到的权限“令牌”
在 getScreenSize()
中,我获取了设备的屏幕分辨率:
private fun getScreenSize(): IntSize {val windowManager = getSystemService(WINDOW_SERVICE) as WindowManagerval screenHeight = windowManager.currentWindowMetrics.bounds.height()val screenWidth = windowManager.currentWindowMetrics.bounds.width()return IntSize(screenWidth, screenHeight)
}
但是如果我直接把这个分辨率传给帮助类创建 MediaCodec
的话会报错:
java.lang.IllegalArgumentExceptionat android.media.MediaCodec.native_configure(Native Method)at android.media.MediaCodec.configure(MediaCodec.java:2214)at android.media.MediaCodec.configure(MediaCodec.java:2130)
不过,这个问题只在某些分辨率较高的设备上出现,猜测是不支持高分辨率视频写入吧,所以我实际上使用时是直接写死一个较小的分辨率,而不是使用设备的分辨率。
然后,在 getMediaProjection()
中,我们通过申请到的权限令牌生成 MediaProjection
:
private fun getMediaProjection(): MediaProjection? {if (resultData == null) {Toast.makeText(this, "未初始化!", Toast.LENGTH_SHORT).show()} else {try {val mediaProjectionManager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManagerreturn mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, resultData!!)} catch (e: IllegalStateException) {Log.e(TAG, "getMediaProjection: ", e)Toast.makeText(this, "ERR: ${e.stackTraceToString()}", Toast.LENGTH_LONG).show()}catch (e: NullPointerException) {Log.e(TAG, "getMediaProjection: ", e)}catch (tr: Throwable) {Log.e(TAG, "getMediaProjection: ", tr)Toast.makeText(this, "ERR: ${tr.stackTraceToString()}", Toast.LENGTH_LONG).show()}}return null
}
最后,通过上面生成的这两个参数初始化录屏帮助类,然后调用 start()
:
// 这里如果直接使用屏幕尺寸会报错 java.lang.IllegalArgumentException
recorder = ScreenRecorder(886, // screenSize.width,1920, // screenSize.height,24,1,mediaProjection,savePath
)CoroutineScope(Dispatchers.IO).launch {try {recorder.start()} catch (tr: Throwable) {Log.e(TAG, "startScreenRecorder: ", tr)recorder.stop()withContext(Dispatchers.Main) {Toast.makeText(this@OverlayService, "录制失败", Toast.LENGTH_LONG).show()}}
}
这里我把开始录屏放到了协程中,实际上由于我们的程序是运行在 Server 中,所以并不是必须在协程中运行。
总结
自此,在安卓中录屏的方法已经全部介绍完毕。
实际上,同样的原理我们也可以用于实现截图。
截图和录屏不同的地方在于,创建虚拟屏幕时改为使用 ImageReader
创建,然后就可以从 ImageReader
获取到 Bitmap。
最后附上完整的 demo 地址: ScreenRecord
相关文章:
2022年都快结束了,还有人不会安卓录屏?在安卓上录制屏幕的的实现方式
前言 在我之前的文章 《以不同的形式在安卓中创建GIF动图》 中,我挖了一个坑,可以通过录制屏幕后转为 GIF 的方式来创建 GIF。只是当时我只是提了这么一个思路,并没有给出录屏的方式,所以本文的内容就是教大家如何通过调用系统 A…...
px rem em rpx 区别 用法
任意浏览器的默认字体高都是16px。所有未经调整的浏览器都符合: 1em16px。那么12px0.75em,10px0.625em。为了简化font-size的换算,需要在css中的body选择器中声明Font-size62.5%,这就使em值变为 16px*62.5%10px, 这样12px1.2em, 10px1em, 也就是说只需要…...

忆享聚焦|ChatGPT、AI、网络数字、游戏……近期热点资讯一览
“忆享聚焦”栏目第十四期来啦!本栏目汇集近期互联网最新资讯,聚焦前沿科技,关注行业发展动态,筛选高质量讯息,拓宽用户视野,让您以最低的时间成本获取最有价值的行业资讯。 目录 行业资讯 1.科技部部长王志…...
[Daimayuan] 树(C++,动态规划,01背包方案数)
有一棵 n n n 个节点的以 1 1 1 号点为根的有根树。现在可以对这棵树进行若干次操作,每一次操作可以选择树上的一个点然后删掉连接这个点和它的儿子的所有边。 现在我们想知道对于每一个 k k k ( 1 ≤ k ≤ n 1≤k≤n 1≤k≤n),最少需要多少次操作能…...
如何选择源代码加密软件
(SDC沙盒)和DLP、文档加密、云桌面等,其优缺点做客观比较如下: 比较内容安全容器(SDC沙盒)DLP文档加密云桌面代表厂家*信达卖咖啡、赛门贴科亿*通、IP噶德、*盾、*途四杰、深*服设计理念以隔离容器加准入技术为基础,构…...
TO-B类软件产品差异化
产品差异化,是在市场众多同质化产品中,突出自身产品亮点的重要方式。对于客户来讲其选择是多种多样的,与其花费大量的时间研究每一家产品的特点,还不如直接选择品牌更大、价格更低的产品来的直接,因此显而易见的突出产…...

设计模式之美-实战一(上):业务开发常用的基于贫血模型的MVC架构违背OOP吗?
领域驱动设计(Domain Driven Design,简称DDD)盛行之后,这种基于贫血模型的传统的开发模式就更加被人诟病。而基于充血模型的DDD开发模式越来越被人提倡。所以,我打算用两节课的时间,结合一个虚拟钱包系统的…...
ChatGPT如何训练自己的模型
ChatGPT是一种自然语言处理模型,它的任务是生成自然流畅的对话。如果想要训练自己的ChatGPT模型,需要进行大量的数据收集、预处理、配置训练环境、模型训练、模型评估等过程。本文将详细介绍这些过程,帮助读者了解如何训练一个高品质的ChatGP…...
springboot使用线程池的实际应用(一)
在实际Spring Boot项目中,我们可以使用Java的原生多线程或者使用Spring自带的线程池进行多线程编程。多线程的好处在于能够提高应用程序的运行效率,特别是在某些计算密集型场景下。以下是一些使用多线程的典型场景: 并发处理请求:…...
ESP-8266学习笔记
1、学习地址 【XMF09F系列资源】基于MicroPython的ESP8266物联网应用开发-赛教资源目录汇总-小蜜蜂笔记 Quick reference for the ESP8266 — MicroPython latest documentation 2、MicroPython及相关开发资源 3、固件烧录与uPyLoader的使用 烧录教程参考: https://www.…...
Java泛型简单的使用
前言 Java里面的泛型在实际开发中运用的很多,学过C的同学一定知道C的模板,而Java中的泛型,一定程度上和它还是挺像的。 相信写Java的人,大都有用过List的实现类ArrayList。在Java没有泛型之前,它的内部是一个Object的…...

深度探索:Qt CMake工程编译后的自动打包策略
深度探索:Qt CMake工程编译后的自动打包策略 1. 引言(Introduction)1.1 Qt和CMake的基本概念(Basic Concepts of Qt and CMake)1.2 自动打包的重要性(Importance of Automatic Packaging) 2. Qt…...

2.7 编译型和解释型
2.7 编译型和解释型 前面我们使用java和javac命令把Hello,World!在控制台输出。那为什么输出,这里我们需要掌握两个知识点。编译型语言和解释型语言。在计算机的高级编程语言就分为编译型语言和解释型语言。而我们的Java既有编译型的特点也有…...

校园网自动登陆(河南科技学院)
1. 介绍 河南科技学院校园网自动登陆(新乡的很多系统相似,可能也可以用?),java版。可以实现电脑,路由器,软路由的自动认证wifi,后续会上传docker版本的。 源码地址 github:https://…...
C++11 override和final关键字
C11中的override和final关键字是为了增强代码的编译时类型检查和面向对象设计中的继承机制。 override关键字用于显示地表明派生类中的成员函数覆盖了基类中的虚函数。当派生类中的函数与基类中的虚函数签名不同或者没有使用override关键字时,编译器会给出警告或错…...
kafka的log存储解析
kafka的log存储解析——topic的分区partition分段segment以及索引等 引言Kafka中的Message是以topic为基本单位组织的,不同的topic之间是相互独立的。每个topic又可以分成几个不同的partition(每个topic有几个partition是在创建topic时指定 的),每个…...

4.文件系统
组成 Linux:一切皆文件 索引节点(I-node) I-node(Index Node):文件系统的内部数据结构,用于管理文件的元数据和数据块。 文件的元数据:包括文件的权限、拥有者、大小、时间戳、索引…...
Shell脚本case in esac分支语句应用
记录:434 场景:Shell脚本case in esac分支语句应用。 版本:CentOS Linux release 7.9.2009。 1.case in esac格式 格式: case 值 in 模式1)expression;; 模式2)expression;; 模式n)expression;; esac 解析:case…...

【线性dp必学四道题】线性dp四道经典例题【最长上升子序列】、【最长公共子序列】、【最长公共上升子序列(maxv的由来)】【最长公共子串】
【最长上升子序列】、【最长公共子序列】、【最长公共上升子序列】 最长上升子序列f[i] 表示以i结尾的最长子序列 最长公共子序列f[i][j] 表示 a前i 和 b前j个 最长公共长度 最长公共上升子序列f[i][j]代表所有a[1 ~ i]和b[1 ~ j]中以b[j]结尾的公共上升子序列的集合 最长公共子…...

追寻幸福:探索幸福的关键特征和行为
目录 1. 积极的心态 2. 良好的人际关系 3. 自我接纳和自尊 4. 追求意义和目标 5. 健康的身心状态 6. 感知和实现个人价值 幸福是一个主观的感受,因此不同的人对于幸福的定义和追求方式可能会有所不同。然而,有一些共同的特点和行为模式,…...
浏览器访问 AWS ECS 上部署的 Docker 容器(监听 80 端口)
✅ 一、ECS 服务配置 Dockerfile 确保监听 80 端口 EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]或 EXPOSE 80 CMD ["python3", "-m", "http.server", "80"]任务定义(Task Definition&…...
树莓派超全系列教程文档--(62)使用rpicam-app通过网络流式传输视频
使用rpicam-app通过网络流式传输视频 使用 rpicam-app 通过网络流式传输视频UDPTCPRTSPlibavGStreamerRTPlibcamerasrc GStreamer 元素 文章来源: http://raspberry.dns8844.cn/documentation 原文网址 使用 rpicam-app 通过网络流式传输视频 本节介绍来自 rpica…...

Redis相关知识总结(缓存雪崩,缓存穿透,缓存击穿,Redis实现分布式锁,如何保持数据库和缓存一致)
文章目录 1.什么是Redis?2.为什么要使用redis作为mysql的缓存?3.什么是缓存雪崩、缓存穿透、缓存击穿?3.1缓存雪崩3.1.1 大量缓存同时过期3.1.2 Redis宕机 3.2 缓存击穿3.3 缓存穿透3.4 总结 4. 数据库和缓存如何保持一致性5. Redis实现分布式…...

【机器视觉】单目测距——运动结构恢复
ps:图是随便找的,为了凑个封面 前言 在前面对光流法进行进一步改进,希望将2D光流推广至3D场景流时,发现2D转3D过程中存在尺度歧义问题,需要补全摄像头拍摄图像中缺失的深度信息,否则解空间不收敛…...

【SQL学习笔记1】增删改查+多表连接全解析(内附SQL免费在线练习工具)
可以使用Sqliteviz这个网站免费编写sql语句,它能够让用户直接在浏览器内练习SQL的语法,不需要安装任何软件。 链接如下: sqliteviz 注意: 在转写SQL语法时,关键字之间有一个特定的顺序,这个顺序会影响到…...
unix/linux,sudo,其发展历程详细时间线、由来、历史背景
sudo 的诞生和演化,本身就是一部 Unix/Linux 系统管理哲学变迁的微缩史。来,让我们拨开时间的迷雾,一同探寻 sudo 那波澜壮阔(也颇为实用主义)的发展历程。 历史背景:su的时代与困境 ( 20 世纪 70 年代 - 80 年代初) 在 sudo 出现之前,Unix 系统管理员和需要特权操作的…...
Element Plus 表单(el-form)中关于正整数输入的校验规则
目录 1 单个正整数输入1.1 模板1.2 校验规则 2 两个正整数输入(联动)2.1 模板2.2 校验规则2.3 CSS 1 单个正整数输入 1.1 模板 <el-formref"formRef":model"formData":rules"formRules"label-width"150px"…...

网站指纹识别
网站指纹识别 网站的最基本组成:服务器(操作系统)、中间件(web容器)、脚本语言、数据厍 为什么要了解这些?举个例子:发现了一个文件读取漏洞,我们需要读/etc/passwd,如…...

【Linux系统】Linux环境变量:系统配置的隐形指挥官
。# Linux系列 文章目录 前言一、环境变量的概念二、常见的环境变量三、环境变量特点及其相关指令3.1 环境变量的全局性3.2、环境变量的生命周期 四、环境变量的组织方式五、C语言对环境变量的操作5.1 设置环境变量:setenv5.2 删除环境变量:unsetenv5.3 遍历所有环境…...
深入理解Optional:处理空指针异常
1. 使用Optional处理可能为空的集合 在Java开发中,集合判空是一个常见但容易出错的场景。传统方式虽然可行,但存在一些潜在问题: // 传统判空方式 if (!CollectionUtils.isEmpty(userInfoList)) {for (UserInfo userInfo : userInfoList) {…...