ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java+Vue构建安卓广告群控系统:从设备接入到任务下发实战解析

2026/9/16 6:27:55 拓冰建站 浏览量
Java+Vue构建安卓广告群控系统:从设备接入到任务下发实战解析 简介基于 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 只做设备发现和边缘管理广告发布不走 ADBADB 在这里的职责收敛到三件事启动阶段自动发现设备、维护设备授权状态、执行少量运维指令如重启播放器、拉取日志。具体做法是通过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 SimpleChannelInboundHandlerJsonNode { 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 vitelatest ad-console -- --template vue cd ad-console npm install npm install vue-router4 pinia mitt依赖里需要额外关注的除了vue-router和pinia还有一个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是任务名称用于后台检索contentTypestring是image / video / m3u8 / webviewcontentUrlstring是素材地址支持 http/httpsplayDurationnumber否单条素材播放秒数缺省按素材时长targetGroupstring是设备分组编码对应 device_info.group_idvolumenumber否播放音量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 ListPublishStrategy strategyList; public PublishStrategyFactory(ListPublishStrategy 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(ListString deviceIds, PublishTask task) { int batchSize 50; for (int i 0; i deviceIds.size(); i batchSize) { ListString 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_id和device_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(fdev_{i:03d})) for i in range(DEVICE_COUNT)] await asyncio.gather(*tasks) asyncio.run(main())脚本跑起来之后在 Vue 控制台上创建一条广告任务观察状态面板里 300 台设备是否全部从 PENDING 推进到 DONE。重点看两点10 秒超时窗口内有没有设备掉回 FAILEDJava 服务进程的内存曲线是否平稳。最后再查一下数据库确认SELECT status, COUNT(*) FROM delivery_record WHERE task_id 10086 GROUP BY status;如果DONE的数量等于 300链路就通了如果存在PUSH_FAILED顺着fail_reason字段去定位是网络不可达还是报文解码异常。这个脚本留到以后加新设备型号时还能回来复用改一下screen字段的值就能模拟异形屏设备。本文还有配套的精品资源点击获取