Android屏幕共享+WebSocket实现传输截图
大致流程为:客户端通过MediaProjectionManager进行屏幕共享,获取屏幕的截图,然后通过WebSocket发送到服务端,然后通过浏览器访问服务端查看传输的截图。
客户端代码分为3部分:
- ScreenShotShareActivity:作用用户的交互界面,有2个按钮,一个是开启屏幕共享,另一个是结束屏幕共享,开启后会启动一个前台服务(这个是系统要求的,屏幕共享的逻辑必须在Service进行)
- ScreenShotEncoder:用于实现屏幕共享的图片截图处理,并设置结果回调,相当于一个工具类,会在Service中调用。
- ScreenCaptureService:前台服务,启动后会监听ScreenShotEncoder的结果,然后通过WebSocket将结果同步给远端服务器。
服务端代码分为2部分:
服务端通过Node js来实现,需要安装socket.io,2个代码文件:
- server.js:作为服务响应
- index.html:前端页面,用来展示客户端截图结果
客户端实现
清单文件配置
ScreenShotShareActivity + ScreenCaptureService 声明
<activity android:name=".demo.sharescreen.ScreenShotShareActivity" /><serviceandroid:name=".demo.sharescreen.ScreenCaptureService"android:exported="false"android:foregroundServiceType="mediaProjection" />
权限声明
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
ScreenShotShareActivity
class ScreenShotShareActivity : AppCompatActivity() {private var isBound = falseprivate var binder: ScreenCaptureService.LocalBinder? = nullprivate var imageView: ImageView? = nullprivate val screenCaptureLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->if (result.resultCode == Activity.RESULT_OK && result.data != null) {startScreenEncoding(result.resultCode, result.data!!)} else {Toast.makeText(this, "屏幕录制权限被拒绝", Toast.LENGTH_SHORT).show()}}private val connection = object : ServiceConnection {override fun onServiceConnected(name: ComponentName?, service: IBinder?) {binder = service as? ScreenCaptureService.LocalBinderbinder?.setImageCallback(object : ScreenShotCaptureCallback {override fun onJpegImageReady(jpegData: String) {// 采集端本地测试val decodedBytes = Base64.decode(jpegData, Base64.NO_WRAP)val bitmap = BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size)runOnUiThread {imageView?.setImageBitmap(bitmap)}}})isBound = true}override fun onServiceDisconnected(name: ComponentName?) {isBound = false}}override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)imageView = ImageView(this).apply {scaleType = ImageView.ScaleType.FIT_CENTER}setContentView(LinearLayout(this).apply {orientation = LinearLayout.VERTICALaddView(Button(this@ScreenShotShareActivity).apply {text = "开始屏幕录制"click {requestScreenCapture()}})addView(Button(this@ScreenShotShareActivity).apply {text = "停止屏幕录制"click {stopScreenCapture()}})addView(imageView, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT,LinearLayout.LayoutParams.MATCH_PARENT))})}private fun stopScreenCapture() {if (isBound) unbindService(connection)val intent = Intent(this@ScreenShotShareActivity, ScreenCaptureService::class.java)stopService(intent)}/*** 请求屏幕共享权限*/private fun requestScreenCapture() {val projectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManagerval intent = projectionManager.createScreenCaptureIntent()screenCaptureLauncher.launch(intent)}/*** 开始屏幕共享,启动前台服务 ScreenCaptureService*/private fun startScreenEncoding(resultCode: Int, data: Intent) {val serviceIntent = Intent(this, ScreenCaptureService::class.java).apply {putExtra("resultCode", resultCode)putExtra("data", data)}ContextCompat.startForegroundService(this, serviceIntent)bindService(serviceIntent, connection, Context.BIND_AUTO_CREATE)}
}
ScreenCaptureService
/*** 前台服务,需要添加权限:* <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />* <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />*/
class ScreenCaptureService : Service() {private var screenShotEncoder:ScreenShotEncoder? = nullprivate var screenShotCallback: ScreenShotCaptureCallback? = nullprivate val socket by lazy {val opts = IO.Options().apply {transports = arrayOf("websocket") // ⭐ 避免 xhr-poll 错误reconnection = truereconnectionAttempts = 5timeout = 5000}IO.socket("http://192.168.28.101:3000",opts)}private fun startForeground(notification: Notification) {if (VERSION.SDK_INT >= Build.VERSION_CODES.Q) {startForeground(1,notification,ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)} else {startForeground(1, notification)}}override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {startForeground(createNotification())val resultCode = intent?.getIntExtra("resultCode", Activity.RESULT_CANCELED) ?: return START_NOT_STICKYval data = intent.getParcelableExtra<Intent>("data") ?: return START_NOT_STICKYinitSocketIO()screenShotEncoder = ScreenShotEncoder(this, resultCode, data)screenShotEncoder?.startCapturing { base64Jpeg ->// 回调给外部activity,可以用做测试或者打印logscreenShotCallback?.onJpegImageReady(base64Jpeg)// 发送给远端服务器socket.emit("frame", base64Jpeg)}return START_STICKY}private fun initSocketIO() {try {// 连接状态监听socket.on(Socket.EVENT_CONNECT) {Log.d("ScreenCaptureService", "✅ Socket 已连接到服务器")}socket.on(Socket.EVENT_CONNECT_ERROR) { args ->Log.e("ScreenCaptureService", "❌ Socket 连接失败: ${args.getOrNull(0)}")}socket.on(Socket.EVENT_DISCONNECT) {Log.w("ScreenCaptureService", "⚠️ Socket 已断开连接")}socket.connect()} catch (e: URISyntaxException) {Log.e("ScreenCaptureService", "❌ Socket URI 格式错误", e)}}private fun createNotification(): Notification {val channelId = "screen_capture"val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManagerif (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {val channel = NotificationChannel(channelId, "屏幕共享", NotificationManager.IMPORTANCE_LOW)manager.createNotificationChannel(channel)}return NotificationCompat.Builder(this, channelId).setContentTitle("屏幕共享中").setContentText("正在采集屏幕内容").setSmallIcon(android.R.drawable.ic_menu_camera).build()}override fun onBind(intent: Intent?): IBinder? {return LocalBinder()}override fun onDestroy() {super.onDestroy()screenEncoder?.stopEncoding()screenShotEncoder?.stopCapturing()socket.disconnect()Log.d("ScreenCaptureService","服务销毁")}inner class LocalBinder : Binder() {fun stopCapture() {stopSelf()}fun setImageCallback(callback: ScreenShotCaptureCallback) {screenShotCallback = callback}}
}interface ScreenShotCaptureCallback {fun onJpegImageReady(jpegData: String)
}
ScreenShotEncoder
class ScreenShotEncoder(private val context: Context,private val resultCode: Int,private val data: Intent,private val width: Int = 720,private val height: Int = 1280,private val dpi: Int = 320
) {private lateinit var mediaProjection: MediaProjectionprivate var imageReader: ImageReader? = nullprivate var handlerThread: HandlerThread? = nullprivate var handler: Handler? = nullprivate var isCapturing = falsefun startCapturing(onFrameReady: (String) -> Unit) {setupMediaProjection()setupImageReader(onFrameReady)isCapturing = true}fun stopCapturing() {isCapturing = falseimageReader?.close()mediaProjection.stop()handlerThread?.quitSafely()}private fun setupMediaProjection() {val projectionManager = context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManagermediaProjection = projectionManager.getMediaProjection(resultCode, data)}private fun setupImageReader(onFrameReady: (String) -> Unit) {imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)handlerThread = HandlerThread("ScreenShotThread").also { it.start() }handler = Handler(handlerThread!!.looper)mediaProjection.createVirtualDisplay("ScreenShotCapture",width,height,dpi,0,imageReader!!.surface,null,handler)handler?.post(object : Runnable {override fun run() {if (!isCapturing) returnval image = imageReader?.acquireLatestImage()if (image != null) {val planes = image.planesval buffer = planes[0].bufferval pixelStride = planes[0].pixelStrideval rowStride = planes[0].rowStrideval rowPadding = rowStride - pixelStride * widthval bitmap = createBitmap(width + rowPadding / pixelStride, height)bitmap.copyPixelsFromBuffer(buffer)image.close()val outputStream = ByteArrayOutputStream()bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)val byteArray = outputStream.toByteArray()val base64Image = Base64.encodeToString(byteArray, Base64.NO_WRAP)onFrameReady(base64Image)bitmap.recycle()}handler?.postDelayed(this, 100) // 每 100ms 截图一帧(约10帧/s)}})}
}
启动App后,看到界面如下:
点击开始录制,会弹一个权限弹窗
同意后,进入录制
服务端实现
第一步:准备环境
安装 Node.js
如果你尚未安装,请去 https://nodejs.org/ 下载并安装 LTS 版本。
安装完成后,打开终端输入:
node -v
npm -v
确认安装成功。
第二步:创建项目文件夹
mkdir webrtc-server
cd webrtc-server
npm init -y
npm install express socket.io
第三步:创建信令服务器 server.js
在webrtc-server文件夹下创建server.js文件,代码如下:
// server.js
const express = require('express');
const app = express();
const http = require('http').createServer(app);
const io = require('socket.io')(http); // v2.4.1 不需要 CORS 配置app.use(express.static(__dirname + '/public'));io.on('connection', socket => {console.log('🟢 Client connected:', socket.id);socket.on('frame', (data) => {console.log(`📥 接收到 frame,长度=${data.length}`);socket.broadcast.emit('frame', data);});socket.on('offer', (data) => {socket.broadcast.emit('offer', data);});socket.on('answer', (data) => {socket.broadcast.emit('answer', data);});socket.on('ice-candidate', (data) => {socket.broadcast.emit('ice-candidate', data);});socket.on('disconnect', () => {console.log('🔴 Client disconnected:', socket.id);});
});const PORT = 3000;
http.listen(PORT, '0.0.0.0', () => {console.log(`🚀 Server running at http://0.0.0.0:${PORT}`);
});
第四步:创建接收端前端页面
在 webrtc-server 目录下创建一个 public/index.html 文件:
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>屏幕共享画面</title><style>#log {background: #f4f4f4;border: 1px solid #ccc;padding: 10px;margin-top: 10px;height: 100px;overflow-y: auto;font-family: monospace;font-size: 14px;}</style>
</head>
<body>
<h2>接收到的屏幕共享图像</h2>
<img id="screenImg"style="max-width: 100%; max-height: 600px; border: 1px solid #ccc; object-fit: contain;" /><div id="log"></div><script src="/socket.io/socket.io.js"></script>
<script>const socket = io();const img = document.getElementById("screenImg");const logEl = document.getElementById("log");function log(message) {const time = new Date().toLocaleTimeString();const entry = `[${time}] ${message}`;console.log(entry);logEl.innerText += entry + "\n";logEl.scrollTop = logEl.scrollHeight;}socket.on("connect", () => {log("✅ 已连接到服务器");});socket.on("disconnect", () => {log("❌ 与服务器断开连接");});socket.on("frame", (base64) => {log("🖼️ 收到一帧图像");img.src = "data:image/jpeg;base64," + base64;});socket.on("connect_error", (err) => {log("⚠️ 连接错误: " + err.message);});
</script>
</body>
</html>
第五步:运行服务器
node server.js
你会看到:
说明服务端运行成功了,并且有一个客户端加入了,这个就是我们的客户端发起的屏幕共享加入的。
验证效果
打开浏览器输入地址:http://192.168.28.101:3000/index.html
注意:这里的地址是局域网地址,我本机电脑的地址,和我手机是在同一个局域网内。
成功的话,可以看到客户端的共享内容: