Java+Vue构建安卓广告群控系统:从设备接入到任务下发实战解析
2026/9/16 6:27:54 网站建设 项目流程

简介:基于 Java 与 Vue 构建的安卓设备广告信息群控发布系统,是一份覆盖管理后台、服务端与安卓投放端的完整工程源码,面向具备 Spring Boot / Vue 基础的全栈开发者、物联网设备集成工程师以及数字标牌项目团队。系统包含广告素材管理、节目制作与发布、设备分组管控等核心模块,技术栈采用 Spring Boot 2.7、MyBatis、Redis、AMQ、Shiro 与 MySQL,前端基于 Element UI,安卓端则通过播放 APP 配合看门狗机制实现稳定投放,能较完整地展示群控发布场景中的权限控制、消息队列与设备状态同步思路。

资源包共 541 个文件,整体约 44.74MB;其中 276 个 Java 文件对应后端逻辑,51 个 Vue 文件与 33 个 JavaScript 文件构成前端交互,111 个 PNG 图片和 XML 布局服务于界面与安卓端资源,另有 SQL 初始化脚本、YML 配置及 ffmpeg.exe 等辅助工具,目录划分清晰,便于按模块阅读和二次开发。目前已有 329 人学习下载。

通过这份源码,读者可以拿到从设备接入、素材上传、节目编排到远程下发的全链路实现参考,也能借助项目中的前后端分离结构理解商业广告屏系统的基础架构;项目说明中注明开源版本可供商业使用但需授权,功能更全面的商业版可另行联系获取。

1. 群控系统最难的不是控住设备,而是把广告可靠地送进屏幕

“群控”两个字容易让人把注意力放在 ADB 和批量指令上,实际做一个广告发布系统,最麻烦的永远是终端的“不确定”:设备掉线、推屏失败、播放器起不来、素材格式不兼容、网络抖动把任务报文吞掉。标题里这个基于 Java 与 Vue 的设计,核心是三层协作——Java 服务端负责任务编排、状态机和 ACK 确认,Vue 控制台负责把设备状态和任务参数变得可见可操作,安卓终端只做一件事:接收报文并执行播放。Java 解决可靠下发,Vue 解决操作体验,安卓端解决播放态。适合正在做商业化运营管理平台、连锁门店屏显、线下广告终端管理的工程师。源码本身的复杂度不高,复杂度全在链路设计上。这篇按通信边界、Vue 链路、批量下发、规模调优的顺序往下拆,每段都能落进项目。

2. 从 ADB 到 Server 端通道:Java 后端如何统一纳管异构安卓设备

广告群控发布系统的终端类型很杂,手机、平板、广告一体机、安卓 TV 盒子都可以是承载端,系统必须先把“异构设备怎么统一建模”这个问题解决。我见过不少项目一上来就拿 ADB 做一切,连广告下发也用adb shell am start去拉 Activity,结果广告有没有真播出、播到第几秒、素材加载失败没法回传,整个系统就是一个只能发不能收的口袋。正确的分层是:ADB 通道管设备发现和边缘管理,业务长连接管任务下发和状态回传,两条通道各自干各自的事,互不干扰,状态视图才立得住。

2.1 ADB 只做设备发现和边缘管理,广告发布不走 ADB

ADB 在这里的职责收敛到三件事:启动阶段自动发现设备、维护设备授权状态、执行少量运维指令(如重启播放器、拉取日志)。具体做法是通过adb connect批量接入同一局域网内的设备,再调用adb devices拿到序列号列表,把这些序列号作为设备唯一标识在系统里建档、绑定门店和分组。这个阶段不承载广告内容下发,因为 ADB 指令没有 ACK 语义,指令发出去了终端的执行结果无法可靠回传,一旦屏幕被锁、应用崩溃,广告任务就无声无息地失败。

真正发布广告的任务通道,我一般会用一个常驻的 Java 服务(Netty 或 Spring Boot WebSocket)与每个终端建立双向长连接。终端开机后主动连上来注册,服务端把设备标记为在线,后续所有广告任务报文都由服务端推给客户端播放器,播放器每播完一个素材回一条执行结果。这样每台设备是“活”的,状态变更能在毫秒级反映到 Vue 控制台上。

2.2 Netty 长连接与消息协议的设计

Netty 在这个场景下比直接用 Tomcat WebSocket 更合适,因为广告群控往往伴随几百到几千路的并发连接,Netty 的 NIO 模型能稳定扛住大量空闲连接,内存占用明显比每连接一线程的 BIO 模型低。消息协议我建议第一步先用 JSON,结构简单、可读性好、Vue 端直接JSON.parse就能消费;等单条报文超过几百字节、设备量过万再考虑切换 Protobuf 压缩字段。

服务端负责处理注册、心跳、ACK 回执的 Handler 长这样:

public class DeviceChannelHandler extends SimpleChannelInboundHandler<JsonNode> { private final DeviceRegistry registry; public DeviceChannelHandler(DeviceRegistry registry) { this.registry = registry; } @Override protected void channelRead0(ChannelHandlerContext ctx, JsonNode msg) { String type = msg.path("type").asText(); String deviceId = msg.path("deviceId").asText(); switch (type) { case "REGISTER" -> doRegister(ctx, msg, deviceId); case "HEARTBEAT" -> registry.refresh(deviceId); case "TASK_ACK" -> registry.traceAck(deviceId, msg.path("taskId").asText(), msg.path("status").asText()); default -> ctx.writeAndFlush(JsonResp.error("UNSUPPORTED_TYPE")); } } }

DeviceChannelHandler只做协议分发,真正的业务逻辑放在DeviceRegistry里。REGISTER 时要从报文中取出屏幕分辨率、安卓系统版本、播放器版本、当前音量这些字段,注册成功后返回REGISTER_OK,终端收到后进入心跳循环。HEARTBEAT 只更新设备最近活跃时间,不携带状态明细,状态明细由心跳之外的日志通道单独上报,避免高频心跳报文过大。

参数方面,心跳间隔我一般设为30 到 60 秒,服务端 3 个心跳周期没有收到就标记离线。间隔太短会放大移动网络下的电量消耗,太长则状态面板卡顿,Vue 端看到一台“在线”设备可能已经死了一分钟。

2.3 设备注册与任务下发的表结构设计

Java 后端把设备、任务、下发记录拆成三张核心表,关系非常直接,写代码不绕弯:

CREATE TABLE device_info ( device_id VARCHAR(64) PRIMARY KEY, group_id VARCHAR(32), screen_width INT, screen_height INT, adb_ip VARCHAR(64), status TINYINT DEFAULT 0, last_heartbeat DATETIME, updated_at DATETIME ) ENGINE = InnoDB; CREATE TABLE publish_task ( task_id BIGINT AUTO_INCREMENT PRIMARY KEY, task_name VARCHAR(128), content_type VARCHAR(16), content_url VARCHAR(512), play_duration INT, target_group VARCHAR(32), status TINYINT DEFAULT 0, create_time DATETIME ) ENGINE = InnoDB; CREATE TABLE delivery_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, task_id BIGINT, device_id VARCHAR(64), status TINYINT DEFAULT 0, ack_time DATETIME, fail_reason VARCHAR(255), KEY idx_task_device (task_id, device_id) ) ENGINE = InnoDB;

device_info.status在线离线只是主状态,播放器运行是否异常建议记在扩展字段里;publish_task.content_type是图片、视频、m3u8 直播流、网页四种,这决定了第 4 章要讲的下发策略;delivery_record最关键,它记录每个任务在每台设备上的执行结果,是后续对账和重推的依据。

3. Vue 控制台的设备监控与任务创建:状态可见比大屏动效更重要

Vue 侧的核心不是画一个漂亮的 3D 大屏,而是做到三件事:设备状态实时变、任务参数能改完就发、设备多的时候页面不卡死。这套系统不追求花哨的视觉效果,数据准确度优先,所以 Vue 端的技术选型直接 Vue 3 + Vite + Pinia,不引入重型图表库,状态变化用轻量列表刷新。

3.1 项目初始化与依赖安装

Vue 3 项目的初始化是常规操作,但真正影响后续开发体验的是依赖安装那一步。公司内网或网络波动环境下,直接npm install会卡在某个包上,我一般会先切换成国内镜像源再装,避免装到一半失败导致 node_modules 残留:

npm config set registry https://registry.npmmirror.com npm create vite@latest ad-console -- --template vue cd ad-console npm install npm install vue-router@4 pinia mitt

依赖里需要额外关注的除了vue-routerpinia,还有一个mitt,它负责在 WebSocket 消息层和组件层之间做事件解耦。设备状态推送进来不直接改组件的 data,而是发一个mitt事件,组件根据需要订阅,避免十几个组件同时监听 Socket 造成内存泄漏。Vite 初始化模板默认不带路由和状态管理,所以上面几条命令是完整跑起来的最小组件集。

3.2 设备列表 WebSocket 的连接管理与断线重连

设备状态面板是控制台的首页,打开页面就要建立连接。Socket 连接不能裸写在组件created里,否则切路由组件销毁时连接就断了。我的做法是把连接管理抽成一个 composable,由路由守卫保证登录后全局只建一条连接:

// src/composables/useDeviceSocket.js import { ref } from 'vue' import mitt from 'mitt' const bus = mitt() const connected = ref(false) let socket = null let retryCount = 0 const SOCKET_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/devices` export function connectDeviceSocket() { if (socket) return socket = new WebSocket(SOCKET_URL) socket.onopen = () => { connected.value = true retryCount = 0 bus.emit('socket:open') } socket.onmessage = (event) => { const msg = JSON.parse(event.data) bus.emit(msg.type, msg.payload) } socket.onclose = () => { connected.value = false socket = null if (retryCount < 10) { retryCount++ setTimeout(connectDeviceSocket, 1000 * retryCount) } } }

这段代码的核心在断线重连逻辑:指数退避,重试上限 10 次。retryCount直接乘到延迟时间上,第一次断线等 1 秒,第二次 2 秒,最多等到 10 秒,不会在大量设备离线时把服务端连接打爆。bus.emit(msg.type, msg.payload)这一行是消息分发点,服务端下发的设备状态、任务 ACK、告警都走同一通道。

3.3 广告发布任务的表单参数设计

在 Vue 控制台创建任务时,表单字段跟服务端表和终端播放器强相关,少了终端没法执行,多了用户不想填。实际使用中按下面这组参数设计最不出错:

参数名类型必填说明
taskNamestring任务名称,用于后台检索
contentTypestringimage / video / m3u8 / webview
contentUrlstring素材地址,支持 http/https
playDurationnumber单条素材播放秒数,缺省按素材时长
targetGroupstring设备分组编码,对应 device_info.group_id
volumenumber播放音量,0-100,缺省沿用当前设置
startTimedatetime定时发布,空则立即执行

这里有个容易踩的坑:contentUrl如果填的是内网 IP 的素材地址,必须保证终端和服务端在同一个局域网可达,很多门店网络带 AP 隔离,终端拿不到服务端 IP 的资源,表现就是任务一直 PENDING 然后超时失败。我一般在表单上直接加一个“连通性预检”按钮,调用一个 Java 接口让目标设备回显素材 HTTP 状态码,200 才允许保存任务。

3.4 素材播放兼容性封装

终端播放器要同时兼容图片、MP4、m3u8 直播流和网页,Vue 端写发布任务时就要考虑素材类型不规范的问题。常见做法是后端在上传/录入素材时用 FFmpeg 探测文件编码,把非 H.264 的视频统一转封装,图片压缩到 1920 宽以内,这样终端不用自己去适配格式。真正需要前端参与的是 m3u8 直播流的处理,因为原生 video 标签在部分安卓 WebView 里不支持 HLS。终端播放器可以直接用 exoPlayer 或者 IJKPlayer,但控制台预览页要能看到直播画面,这时引入hls.js是最直接的方案:

import Hls from 'hls.js' export function createPreviewPlayer(videoEl, url) { if (videoEl.canPlayType('application/vnd.apple.mpegurl')) { videoEl.src = url } else if (Hls.isSupported()) { const hls = new Hls() hls.loadSource(url) hls.attachMedia(videoEl) hls.on(Hls.Events.ERROR, (_, data) => { if (data.fatal) { console.error(`[hls.js] fatal error: ${data.type}`) hls.destroy() } }) } }

这段兼容逻辑在 iOS Safari 上走原生播放,在安卓 WebView 和 PC Chrome 上走 hls.js,覆盖了绝大多数线上预览场景。hls.js 的fatal错误要销毁实例而不是重新 loadSource,否则网络闪断后会出现无限报错循环。

4. 任务下发与执行确认:策略模式、状态机和幂等消费

Java 后端下发广告任务,最忌讳的是用一堆if/else把不同类型任务的逻辑堆在同一个类里。广告类型多了以后,图片、离线视频、直播流、网页轮播的处理逻辑差异很大,我采用策略模式做类型分派,再配合状态机管理每个任务的执行阶段。这个设计在 Java 后端面试里也常被当作场景题来问,核心是开闭原则——加一种素材类型不需要改动下发主流程,只新增一个策略实现类。

4.1 用策略模式解耦广告类型的分发逻辑

先定策略接口,再建一个工厂根据contentType取具体策略:

public interface PublishStrategy { boolean supports(String contentType); PublishTask buildPublishModel(Long taskId, JsonNode params); } @Component public class ImagePublishStrategy implements PublishStrategy { @Override public boolean supports(String contentType) { return "image".equals(contentType); } @Override public PublishTask buildPublishModel(Long taskId, JsonNode params) { return PublishTask.builder() .type("image") .contentUrl(params.path("contentUrl").asText()) .playDuration(params.path("playDuration").asInt(15)) .build(); } }

对外暴露的工厂类用 Spring 注入策略列表,循环匹配supports方法:

@Service public class PublishStrategyFactory { private final List<PublishStrategy> strategyList; public PublishStrategyFactory(List<PublishStrategy> strategyList) { this.strategyList = strategyList; } public PublishStrategy getStrategy(String contentType) { return strategyList.stream() .filter(s -> s.supports(contentType)) .findFirst() .orElseThrow(() -> new IllegalArgumentException("Unsupported contentType: " + contentType)); } }

这样保证每次新增广告形式时,服务端发布主流程不动,只往 Spring 容器里塞一个实现类。后面接 Android TV 大屏、电梯屏这种新终端时,复用同一套策略结构,只是终端报文处理不同。

4.2 分批下发与 ACK 确认机制

设备量大时一次把几百台设备的任务报文同时塞进 Netty 通道,会造成瞬间内存高峰,弱网设备更容易丢包。我一般会做批次控制,每批 50 台,一批发完收到全部 ACK 再发下一批,同时设置单批超时。超时的设备收回重推队列:

public void dispatchWithAck(List<String> deviceIds, PublishTask task) { int batchSize = 50; for (int i = 0; i < deviceIds.size(); i += batchSize) { List<String> batch = deviceIds.subList(i, Math.min(i + batchSize, deviceIds.size())); CountDownLatch latch = new CountDownLatch(batch.size()); for (String deviceId : batch) { channelGroup.find(deviceId).writeAndFlush(wrapTaskMessage(task)) .addListener(future -> { if (!future.isSuccess()) { recordDelivery(deviceId, task.getTaskId(), "PUSH_FAILED"); } latch.countDown(); }); } boolean allAcked = latch.await(10, TimeUnit.SECONDS); if (!allAcked) { retryQueue.offer(batch); } } }

注意这段代码是“异步确认 + 倒计数闩”的简化模型:writeAndFlush的 listener 只代表消息写进 Netty 通道成功,不代表终端已收到,严格的 ACK 要等到终端 TASK_ACK 回执才算数。delivery_record表里的状态要按 ACK 回执更新,而不是按writeAndFlush成功更新。建议把delivery_record.status设计成 0 待推送、1 已推送未确认、2 已确认播放、3 播放失败,这样每台设备在链路里处于哪个阶段一眼能查出来。

4.3 任务执行状态机与失败重推

稍规范的群控系统都要给任务定义一套状态机,不能只靠一张布尔字段解决问题。广告任务从创建到终端的生命周期我划分为 6 个状态:

状态含义触发时机
DRAFT草稿用户保存未发布
PENDING待下发点击发布,进入调度队列
PUSHING下发中服务端向设备推送报文
PLAYING播放中收到终端播放开始回执
DONE已完成收到播放完成回执
FAILED失败超时未确认或终端主动上报失败

状态推进用一张更新表驱动,明确幂等键,这里容易出现重复下发:服务端网络超时后重推,终端已经播过了又会再播一次。解法是终端侧对taskId + deviceId + processorId做去重记录,Java 后端也记录publish_task.statusFAILED的任务再次点击重推时先检查终端是否已经上报过 ACK。

4.4 动态代理在链路监控里的应用

状态机跑起来之后,要监控每个状态下方法调用的耗时和失败率,我会给PublishStrategyFactory或任务调度 Service 加一个基于动态代理的监控切面。Spring AOP 默认走 JDK 动态代理,可以拦截所有策略类方法的入参和返回,把请求耗时记录到日志表:

@Aspect @Component public class PublishMonitorAspect { @Around("execution(* com.ad.service.PublishStrategy+.*(..))") public Object logPublish(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); try { Object result = pjp.proceed(); logger.info("strategy={} cost={}ms", pjp.getSignature().getDeclaringType().getSimpleName(), System.currentTimeMillis() - start); return result; } catch (Exception e) { logger.error("publish failed: {}", pjp.getSignature().getName(), e); throw e; } } }

这里不需要每个策略类自己埋点,切面统一记录。Java 动态代理本身的性能损耗在这个业务量级下可以忽略,但能换来完整的调用链耗时统计,排查弱网环境下“哪一步慢”非常有用。Netty 通道的 write 耗时、终端 ACK 延迟、FFmpeg 转码耗时,汇总到一张监控表里就能定位群控系统的瓶颈。

5. 规模上去之后的三个调优点与一套可复现的压测脚本

设备量从 50 台做到 1000 台,最先暴露问题的往往不是功能逻辑,而是心跳写库、日志量、重试风暴这些边缘环节。下面三个点是我在类似项目里真实调过的位置,按重要程度从高到低列出来,每个都可以直接照做。

5.1 心跳与状态日志改成批量聚合写入

每台设备 30 秒心跳一次,1000 台设备每分钟就是 2000 条心跳,直接逐条 INSERT 明显给 MySQL 增加无谓压力。我一般先把 Linux 的 TCP 连接数核到 65535,再让设备心跳在 Java 内存里聚合 5 秒,统一INSERT ... VALUES (...),(...),批量 100 条一次写入:

INSERT INTO device_info (device_id, status, last_heartbeat, updated_at) VALUES ('dev_a', 1, NOW(), NOW()), ('dev_b', 1, NOW(), NOW()) ON DUPLICATE KEY UPDATE status = VALUES(status), last_heartbeat = VALUES(last_heartbeat);

这里利用ON DUPLICATE KEY UPDATE按主键device_id做幂等更新,不需要每次都SELECT出来判断状态。测试环境跑一周后观察device_info行数,应该稳定在设备总数,不会因为终端重新注册产生垃圾数据。

5.2 下发记录表的索引优化

delivery_record表千万行级别之前就要把索引做好,否则页面上按任务查看设备执行详情时会非常慢。常用查询是按task_iddevice_id联合过滤状态,所以索引要覆盖核心查询路径:

ALTER TABLE delivery_record ADD KEY idx_task_status (task_id, status), ADD KEY idx_device_ack (device_id, ack_time);

第一条索引加速“查某任务下哪些设备没确认”的重推逻辑,第二条索引加速“查某设备最近确认过的任务”的去重判定。两张索引在写入时有一定损耗,但对广告群控这种读多写少的场景完全值得。

5.3 模拟 300 台设备验证全链路的压测脚本

最后是压测验证。不需要真机,写一个 Python 脚本模拟设备注册、心跳和 ACK 回执,直接连接到 Java Netty 服务端:

import asyncio import json import websockets DEVICE_COUNT = 300 async def fake_device(device_id): uri = "ws://192.168.1.100:8080/ws/devices" async with websockets.connect(uri) as ws: await ws.send(json.dumps({"type": "REGISTER", "deviceId": device_id, "screen": "1920x1080"})) while True: await ws.send(json.dumps({"type": "HEARTBEAT", "deviceId": device_id})) await asyncio.sleep(30) async def main(): tasks = [asyncio.create_task(fake_device(f"dev_{i:03d}")) for i in range(DEVICE_COUNT)] await asyncio.gather(*tasks) asyncio.run(main())

脚本跑起来之后,在 Vue 控制台上创建一条广告任务,观察状态面板里 300 台设备是否全部从 PENDING 推进到 DONE。重点看两点:10 秒超时窗口内有没有设备掉回 FAILED;Java 服务进程的内存曲线是否平稳。最后再查一下数据库确认:

SELECT status, COUNT(*) FROM delivery_record WHERE task_id = 10086 GROUP BY status;

如果DONE的数量等于 300,链路就通了;如果存在PUSH_FAILED,顺着fail_reason字段去定位是网络不可达还是报文解码异常。这个脚本留到以后加新设备型号时还能回来复用,改一下screen字段的值就能模拟异形屏设备。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询