更新记录
1.0.1(2026-09-15)
无
1.0.0(2026-09-15)
主要适用于 安卓以及鸿蒙悬浮球,兼容 vue2 vue3
平台兼容性
uni-app(4.24)
| Vue2 | Vue2插件版本 | Vue3 | Vue3插件版本 | Chrome | Safari | app-vue | app-nvue | Android | iOS | 鸿蒙 |
|---|---|---|---|---|---|---|---|---|---|---|
| √ | 1.0.0 | √ | 1.0.0 | - | - | - | - | - | × | - |
| 微信小程序 | 支付宝小程序 | 抖音小程序 | 百度小程序 | 快手小程序 | 京东小程序 | 鸿蒙元服务 | QQ小程序 | 飞书小程序 | 小红书小程序 | 快应用-华为 | 快应用-联盟 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| × | × | × | × | × | × | × | × | × | × | - | - |
uni-app x(4.24)
| Chrome | Safari | Android | iOS | 鸿蒙 | 微信小程序 |
|---|---|---|---|---|---|
| × | × | √ | × | - | × |
其他
| 多语言 | 暗黑模式 | 宽屏模式 |
|---|---|---|
| √ | × | √ |
lw-overlay
Android 桌面悬浮窗 UTS 插件 —— 基于 WindowManager + TYPE_APPLICATION_OVERLAY + 前台 Service,UI 由业务侧 HTML/WebView 自定义。
环境要求
- HBuilderX ≥ 4.25(UTS 原生混编)
- Android 6.0+(API 23+,SYSTEM_ALERT_WINDOW 权限)
- Android 14+(API 34+)需声明
foregroundServiceType="specialUse"(插件已声明)
一、安装
将本目录放入项目 uni_modules/,HBuilderX 重新编译。涉及原生代码,必须自定义基座/正式包才能真机运行,普通预览不生效。
二、快速开始
2.1 权限判断与申请
import { requestPermission } from '@/uni_modules/lw-overlay'
// #ifdef APP-ANDROID
requestPermission((granted) => {
if (granted) {
// 已授权,可调 showOverlay
} else {
// Android 端 requestPermission 内部已跳转系统设置页
// 用户从设置页返回 App 后,在 onShow 内再次调用本方法复查
}
})
// #endif
| 平台 | requestPermission 行为 |
|---|---|
| Android | 已授权 → callback(true);未授权 → 跳系统设置页 + callback(false) |
| 鸿蒙 | 仅检查授权状态(SYSTEM_FLOAT_WINDOW)。申请必须在 EntryAbility 内调 requestPermissionsFromUser,详见「十、鸿蒙集成说明」 |
需要将「判断」与「跳转」分开控制时,用拆分后的两个方法:
import { checkOverlayPermission, openOverlayPermissionSetting } from '@/uni_modules/lw-overlay'
if (!checkOverlayPermission()) {
// 自行决定何时跳转(如弹窗确认后再跳)
openOverlayPermissionSetting()
}
Android 13+ 通知权限单独申请(系统级 runtime permission):
// #ifdef APP-ANDROID
plus.android.requestPermissions(['android.permission.POST_NOTIFICATIONS'])
// #endif
Android 从设置页返回时,App 不会自动收到授权变化。建议在
onShow内复检:
onShow() {
// #ifdef APP-ANDROID
requestPermission((granted) => { this.granted = granted })
// #endif
}
2.2 球态悬浮窗(最小可用)
单态球:只显示一个圆形悬浮球,可拖动,球内文案通过 data 注入。
import { showOverlay, updateOverlay } from '@/uni_modules/lw-overlay'
showOverlay({
initialX: 40,
initialY: 240,
width: 60,
height: 60, // 单态用 width/height
draggable: true,
notificationTitle: '业务球运行中',
html: `
<!DOCTYPE html>
<html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no">
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
html,body{width:100%;height:100%;overflow:hidden;background:transparent;}
.ball{
width:100%;height:100%;border-radius:50%;
background:linear-gradient(135deg,#4080FF,#7C4DFF);
display:flex;align-items:center;justify-content:center;
color:#fff;font-size:14px;font-weight:600;
box-shadow:0 4px 12px rgba(0,0,0,0.3);
user-select:none;
}
</style></head>
<body>
<div class="ball" id="num">0</div>
<script>
window.__lw_overlay = {
data: {},
update(d) {
this.data = d || {};
document.getElementById('num').textContent =
(this.data.count != null) ? this.data.count : '0';
}
};
document.body.addEventListener('click', function(){
if (window.__lwNative && window.__lwNative.emit) {
window.__lwNative.emit('tap', '{}');
}
});
</script>
</body></html>
`,
data: { count: 5 }, // 初始文案
style: { // 外层容器样式
cornerRadius: 30, // 圆形 = 球尺寸一半
backgroundColor: 'transparent',
elevation: 6
},
onSuccess() { console.log('球已显示') },
onFail(err) { console.warn(err.errCode, err.errMsg) }
})
// 动态改文案
updateOverlay({ data: { count: 99 } })
球样式调整速查
| 改动 | 在哪里改 |
|---|---|
| 球内文字 / 数字 | HTML update(d) 内 DOM 操作 + updateOverlay({ data }) 触发 |
| 球内图标 | HTML <img> / SVG / CSS |
| 球的颜色 / 圆角 / 阴影 | HTML CSS(.ball 类)或 options.style(外层容器) |
| 球尺寸 | options.width / height(dp) |
| 球初始位置 | options.initialX / initialY(dp) |
2.3 双态:球 + 展开面板
双态模式 = 同时提供 ballSize 和 panelSize,通过 updateOverlay({ mode }) 切换。
import { showOverlay, updateOverlay } from '@/uni_modules/lw-overlay'
showOverlay({
initialX: 40,
initialY: 240,
width: 60, height: 60, // 兜底,双态不消费
ballSize: { width: 60, height: 60 },
panelSize: { width: 280, height: 220 },
initialMode: 'ball',
draggable: true, // 仅球态生效,面板态强制不拦截
notificationTitle: '业务悬浮窗',
htmlUrl: 'https://example.com/overlay.html', // 或 html / htmlPath
data: { title: '今日提醒', desc: '3 条待办' },
style: { backgroundColor: 'transparent', elevation: 8 }
})
// 球 → 面板
updateOverlay({ mode: 'panel', data: { title: '今日提醒', desc: '3 条待办' } })
// 面板 → 球
updateOverlay({ mode: 'ball' })
HTML 内如何感知 mode 切换:插件会在 update(data) 注入的 data 中自动追加 mode 字段:
// HTML 内
window.__lw_overlay = {
data: {},
update(d) {
this.data = d || {};
if (this.data.mode === 'panel') {
// 渲染面板 UI:title、desc、按钮等
} else {
// 渲染球 UI:单个数字 / 图标
}
}
};
球态点击切换到面板态(HTML 触发 → uni 监听 → 调 updateOverlay):
// HTML 内
document.body.addEventListener('click', function(){
window.__lwNative.emit('expand', '{}');
});
// uni 端
onOverlayEvent((event, payload) => {
if (event === 'expand') updateOverlay({ mode: 'panel' })
if (event === 'collapse') updateOverlay({ mode: 'ball' })
})
面板态拖动:面板态原生层强制 dragEnabled=false,事件透传给 HTML。HTML 内某个元素(如标题栏)监听 touchmove → __lwNative.drag(dx, dy) 触发原生层移动整个悬浮窗。详见「六、HTML ↔ 原生 通信协议」。
鸿蒙端面板态拖动用 ArkUI 内 PanGesture +
emitter.emit({ eventId: 1, data: { dx, dy } })。
2.4 通知栏配置与点击
| 字段 | 默认 | 说明 |
|---|---|---|
notificationTitle |
'悬浮窗运行中' |
通知标题 |
notificationContent |
'悬浮窗运行中' |
通知副文本 |
notificationSmallIcon |
应用 icon | /static/notify.png;Android 14+ 因 IconCompat 不可用,退化用应用自身图标,此字段当前不生效 |
notificationClick.bringAppToFront |
true |
点击通知是否拉起应用到前台 |
showOverlay({
// ...其他
notificationTitle: '业务悬浮窗运行中',
notificationContent: '点击返回 App',
notificationClick: { bringAppToFront: true }
})
监听通知点击:
onOverlayEvent((event, payload) => {
if (event === 'notificationClick') {
console.log('用户点击了通知栏')
// 默认行为:拉起 App 前台(由 bringAppToFront 控制)
// 这里可额外做:跳页、上报、Toast 等
uni.navigateTo({ url: '/pages/overlay/overlay' })
}
})
通知小图标恢复方法(业务侧自定义图标):当前 Android 14+ 因 androidx 依赖缺失,notificationSmallIcon 字段已不消费。如需自定义图标,可在宿主 AndroidManifest 内单独覆盖通知资源,或等待后续版本修复 IconCompat 依赖。
2.5 监听方法
所有 on* 监听器都是单 callback 覆盖式赋值(注册一次长期生效,重复注册只保留最后那个),插件内部 keepAlive 自动处理:
| 方法 | 触发时机 | 回调签名 |
|---|---|---|
onOverlayStateChange |
shown / hidden / permission_denied / error |
(state, info: {x, y}),x/y 单位 dp |
onOverlayMove |
拖动过程持续触发 | (x, y) 单位 dp |
onOverlayTap |
球态被点击(HTML 内 emit('tap') 或容器手势) |
() |
onOverlayEvent |
HTML 自定义事件 / 通知点击 | (event, payload) payload 已 parse 为 UTSJSONObject |
import {
onOverlayStateChange,
onOverlayMove,
onOverlayTap,
onOverlayEvent,
isOverlayShowing
} from '@/uni_modules/lw-overlay'
onLoad() {
// 状态变化
onOverlayStateChange((state, info) => {
console.log('state:', state, 'pos:', info.x, info.y)
switch (state) {
case 'shown': uni.showToast({ title: '悬浮窗已显示', icon: 'none' }); break
case 'hidden': uni.showToast({ title: '已隐藏', icon: 'none' }); break
case 'permission_denied': uni.showToast({ title: '请先授权悬浮窗权限', icon: 'none' }); break
case 'error': uni.showToast({ title: '悬浮窗出错', icon: 'none' }); break
}
})
// 拖动过程(用于磁吸 / 边缘吸附)
onOverlayMove((x, y) => {
if (x < 20) updateOverlay({ data: { snapped: 'left' } })
})
// 点击(球态)
onOverlayTap(() => {
console.log('球被点击')
updateOverlay({ mode: 'panel' }) // 切到面板态
})
// HTML 自定义事件 + 通知点击
onOverlayEvent((event, payload) => {
console.log('HTML event:', event, payload)
// 常见 event 值:
// 'tap':HTML 内 emit('tap') 触发(同时也会触发 onOverlayTap)
// 'notificationClick':通知栏点击
// 'expand' / 'collapse':业务自定义(HTML 内 emit 触发)
// 任意业务事件名
})
}
onShow() {
// 检查当前是否在显示(用于页面恢复时刷新 UI)
const showing = isOverlayShowing()
console.log('当前悬浮窗状态:', showing ? '显示中' : '已隐藏')
}
覆盖语义:再次调用
onOverlayXxx(cb)会替换前一个 callback。如需多端订阅,业务侧自行维护分发。
2.6 隐藏与状态判断
import { hideOverlay, isOverlayShowing } from '@/uni_modules/lw-overlay'
// 主动隐藏
hideOverlay()
// 判断当前是否在显示
if (isOverlayShowing()) {
// 已有悬浮窗,更新即可
updateOverlay({ data: { ... } })
} else {
// 重新 showOverlay
}
单例约束:插件用静态字段持有状态,同一时刻只允许一个悬浮窗。再次
showOverlay前必须hideOverlay,否则返回错误码9020004。
2.7 平台条件编译示例
import {
requestPermission,
showOverlay,
onOverlayEvent
} from '@/uni_modules/lw-overlay'
// 权限申请方式按平台分流
function ensurePermission(cb) {
// #ifdef APP-ANDROID
requestPermission(cb)
// #endif
// #ifdef APP-HARMONY
// 鸿蒙权限需在 EntryAbility 内提前申请,这里直接检查
requestPermission(cb)
// #endif
}
showOverlay({
initialX: 40, initialY: 240,
width: 60, height: 60,
ballSize: { width: 60, height: 60 },
panelSize: { width: 280, height: 220 },
initialMode: 'ball',
draggable: true,
notificationTitle: '业务悬浮窗',
// 鸿蒙端 html/htmlUrl/htmlPath 不生效,UI 在 ArkUI 页面内
htmlPath: '/static/overlay.html',
data: { title: 'hello' }
})
onOverlayEvent((event, payload) => {
console.log(event, payload)
})
三、API
| 方法 | 签名 | 说明 |
|---|---|---|
requestPermission |
(cb: (granted: boolean) => void) => void |
组合便捷方法:检查权限,未授权跳设置页 |
checkOverlayPermission |
() => boolean |
仅检查是否已授权,不跳转 |
openOverlayPermissionSetting |
() => void |
跳转系统悬浮窗权限设置页(鸿蒙端空实现) |
showOverlay |
(options: OverlayOptions) => void |
启动悬浮窗(单态或双态) |
updateOverlay |
(payload: OverlayPayload) => void |
更新 data / 切换 mode(不传则保持) |
pushOverlay |
(name: string, msg: UTSJSONObject) => void |
向 HTML 推送业务消息(FloatingBall* 协议),HTML 内 __lw_overlay.push(name, msg) 消费 |
hideOverlay |
() => void |
隐藏并销毁悬浮窗 |
isOverlayShowing |
() => boolean |
当前是否在显示 |
onOverlayStateChange |
(cb: (state, info) => void) => void |
状态变化持续回调,info = {x, y}(单位 dp) |
onOverlayMove |
(cb: (x, y) => void) => void |
拖动持续回调(单位 dp) |
onOverlayTap |
(cb: () => void) => void |
容器被点击(球态触发;面板态需 HTML 内 emit('tap')) |
onOverlayEvent |
(cb: (event, payload) => void) => void |
HTML → 原生 事件持续回调 |
on*系列监听单 callback 自动 keepAlive,注册一次即可长期生效。
四、OverlayOptions
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
initialX / initialY |
number | ✓ | 初始位置,单位 dp |
width / height |
number | ✓ | 兜底尺寸,单位 dp(双态下不会用到) |
ballSize |
{ width, height } |
双态必填 | 球态尺寸 |
panelSize |
{ width, height } |
双态必填 | 面板态尺寸 |
initialMode |
'ball' \| 'panel' |
✗ | 双态初始状态,默认 'ball' |
draggable |
boolean | ✗ | 是否允许拖动,默认 true。球态生效;面板态由 HTML 自行处理拖动(见七) |
panelHandleHeight |
number | ✗ | 面板态顶部「原生拖动把手」高度 dp(双态生效,默认 0 禁用)。区域内拖动由原生层接管,与球态同等流畅;轻点不拦截,HTML 按钮不受影响 |
notificationTitle |
string | ✗ | 前台服务通知标题 |
html |
string | ✗ | HTML 内容字符串 |
htmlUrl |
string | ✗ | HTML 网络 URL,WebView 直接 loadUrl |
htmlPath |
string | ✗ | uni 静态目录下的 HTML 文件路径,例 /static/overlay.html |
data |
UTSJSONObject | ✗ | 初始数据,注入 HTML 全局 |
style |
OverlayStyle | ✗ | 容器样式 |
enableJs |
boolean | ✗ | 是否启用 JS,默认 true |
onSuccess |
() => void |
✗ | 启动成功 |
onFail |
(err: OverlayFail) => void |
✗ | 启动失败 |
HTML 加载优先级:html > htmlUrl > htmlPath > 内置默认模板。
OverlayStyle
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
backgroundColor |
string | 'transparent' |
#RRGGBB / #AARRGGBB / 'transparent' |
cornerRadius |
number | 0 |
圆角 dp |
borderColor |
string | '' |
描边色 |
borderWidth |
number | 0 |
描边宽 dp |
elevation |
number | 0 |
阴影 dp |
圆角/描边作用于外层容器;HTML 内的圆角/阴影由 HTML 自己控制。
OverlayPayload(updateOverlay 参数)
| 字段 | 类型 | 说明 |
|---|---|---|
data |
UTSJSONObject | 业务任意 KV,覆盖式注入到 window.__lw_overlay.data |
mode |
'ball' \| 'panel' |
切换状态;不传保持当前状态 |
五、生命周期
5.1 应用启动
App 启动
└─ AppHookProxy.onCreate
└─ if (isPrivacyAgree()) → 创建 NotificationChannel
Channel 创建仅在用户同意隐私协议后执行,符合合规要求。
5.2 显示悬浮窗
showOverlay(options)
├─ 若 OverlayService.isShowing → onFail(9020004)
├─ 若 !Settings.canDrawOverlays → notifyState('permission_denied') + onFail(9020001)
├─ 写入 pending 字段(html / htmlUrl / data / style / 尺寸等)
└─ OverlayService.start(ctx)
└─ ctx.startForegroundService(intent)
└─ onStartCommand
├─ startForeground(NOTI_ID, notification, specialUse) // 显示通知
└─ attachOverlay()
├─ WindowManager.addView(root, params)
├─ isShowing = true
└─ notifyState('shown') → onOverlayStateChange 回调
5.3 更新内容
updateOverlay(payload)
├─ 若 dualMode 且 payload.mode 与当前不同 → OverlayService.applyMode(mode)
│ ├─ 修改 LayoutParams.width / height
│ ├─ wm.updateViewLayout(root, lp)
│ └─ TouchFrameLayout.dragEnabled = (mode != 'panel') && draggable
└─ OverlayService.pushData(dataJson)
└─ WebView.evaluateJavascript('window.__lw_overlay.update(json)')
5.4 隐藏悬浮窗
hideOverlay()
└─ ctx.stopService(intent)
└─ Service.onDestroy
└─ detachOverlay()
├─ WebView.destroy()
├─ WindowManager.removeView(root)
├─ isShowing = false
└─ notifyState('hidden') → onOverlayStateChange 回调
5.5 OverlayState 枚举
| state | 触发时机 |
|---|---|
'shown' |
addView 成功 |
'hidden' |
detachOverlay 完成 |
'permission_denied' |
canDrawOverlays = false |
'unsupported' |
系统版本低于 Android 6.0(实际不会触发,编译期已要求) |
'error' |
addView 抛异常 |
六、HTML ↔ 原生 通信协议
6.1 原生 → HTML(注入数据)
插件向 WebView 注入全局对象 window.__lw_overlay,业务 HTML 必须实现 update(data):
window.__lw_overlay = {
data: {},
update: function(d) {
this.data = d || {};
// 业务自定义渲染逻辑
}
};
调用时机:
- HTML
onPageFinished后立即注入初始data - 每次
updateOverlay({ data })调用都会触发update
6.2 HTML → 原生(事件上报)
插件向 WebView 注入 window.__lwNative,暴露两个方法:
emit(event: string, payloadJson: string)
window.__lwNative.emit('collapse', JSON.stringify({ ts: Date.now() }));
业务侧通过 onOverlayEvent((event, payload) => { ... }) 接收,payload 已被插件解析为 UTSJSONObject。
特殊事件:
emit('tap', '{}')→ 同时触发onOverlayTapemit('collapse', '{}')→ 插件原生直接折叠回小球(双态下自动切容器尺寸并推 mode 给 HTML),业务侧无需再调updateOverlay({ mode: 'ball' });事件仍会正常回调onOverlayEvent- 其他事件名由业务自定义
drag(dx: number, dy: number) —— 面板态拖动专用
球态由原生 TouchFrameLayout 直接处理拖动(性能更好)。面板态原生层不拦截事件,HTML 内需自行监听 touchmove,调用 __lwNative.drag(dx, dy) 通知原生更新位置。
var head = document.querySelector('.drag-handle');
var sx = 0, sy = 0, dragging = false;
head.addEventListener('touchstart', function(e) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
dragging = true;
}, { passive: true });
head.addEventListener('touchmove', function(e) {
if (!dragging) return;
e.preventDefault();
var t = e.touches[0];
var dx = Math.round(t.clientX - sx);
var dy = Math.round(t.clientY - sy);
sx = t.clientX;
sy = t.clientY;
window.__lwNative.drag(dx, dy);
}, { passive: false });
head.addEventListener('touchend', function() { dragging = false; });
6.3 双态 mode 注入
开启双态(同时提供 ballSize + panelSize)时,每次 update 的 data 中会自动注入 mode 字段('ball' 或 'panel')。HTML 据此切换不同 UI:
update: function(d) {
this.data = d || {};
if (this.data.mode === 'panel') {
// 显示面板 UI
} else {
// 显示球 UI
}
}
七、双态模式要点
| 场景 | 容器尺寸 | dragEnabled | 触摸事件流向 |
|---|---|---|---|
单态(只设 width/height) |
固定 | draggable |
容器拦截,原生拖动 |
| 双态球态 | ballSize |
draggable |
容器拦截,原生拖动 |
| 双态面板态 | panelSize |
强制 false |
透传给 WebView,HTML 内交互正常 |
面板态强制不拦截是为了 HTML 内按钮、链接、滚动条等交互生效。若面板态也需要整体拖动,用「拖动把手」方案:HTML 内某个元素(如头部)监听 touchmove →
__lwNative.drag(dx, dy)。
双态切换位置策略:
- 球态 → 面板态:面板自动移动到屏幕居中(按
displayMetrics可视区域计算;面板大于屏幕时贴左上角,不超出屏幕) - 面板态 → 球态:球恢复展开前的位置(展开时自动记忆,收回后 clamp 在新屏幕范围内)
- 面板态
drag(dx, dy)拖动:原生层 clamp 在屏幕可视范围内,与球态拖动边界一致
面板拖动性能:推荐 panelHandleHeight 原生把手方案——down 落在面板顶部把手区域、move 超 touch slop 时由原生层直接接管拖动(复用球态代码路径,帧级响应);轻点及把手外区域事件全部透传 HTML。drag(dx, dy) JS 桥接路径作为兼容保留(经 WebView 事件分发 + 跨线程桥接,跟手性不如原生)。
八、错误码
| 码 | 含义 |
|---|---|
| 9020001 | 悬浮窗权限被拒绝 |
| 9020002 | 当前平台/系统版本不支持 |
| 9020003 | Window 创建失败 |
| 9020004 | 已有悬浮窗在显示,请先 hideOverlay |
| 9020005 | 当前无悬浮窗在显示 |
| 9020007 | HTML 文件读取失败(htmlPath 模式) |
错误对象实现 IUniError:{ errSubject: 'lw-overlay', errCode, errMsg }。
九、Android 集成说明
9.1 权限(已在插件 AndroidManifest.xml 声明)
SYSTEM_ALERT_WINDOW—— 悬浮窗FOREGROUND_SERVICE+FOREGROUND_SERVICE_SPECIAL_USE—— 前台 Service(Android 14+)POST_NOTIFICATIONS—— 通知(Android 13+ 运行时权限,业务侧主动申请)
POST_NOTIFICATIONS 申请示例:
// #ifdef APP-ANDROID
plus.android.requestPermissions(['android.permission.POST_NOTIFICATIONS'])
// #endif
9.2 HTTP 明文流量(htmlUrl 模式用)
插件 AndroidManifest 已声明 android:usesCleartextTraffic="true",允许 HTTP。生产环境强烈建议用 HTTPS。
9.3 后台保活
- 前台 Service + 通知常驻
- 国产 ROM(小米/华为/OPPO/Vivo)需引导用户加入电池白名单
- Android 14+ 已声明
foregroundServiceType="specialUse"+ propertyuser overlay window
9.4 调试
adb logcat -s LWOverlay:* LwJsBridge:*
关键日志:
attachOverlay: addView SUCCESS size=WxH—— addView 成功attachOverlay addView FAILED: ...—— addView 抛异常onPageFinished url=...—— HTML 加载完成onReceivedError: ... (ERR_*)—— WebView 加载失败JS emit: event=X payload=Y—— HTML 上报事件applyMode: mode=panel size=WxH dragEnabled=false—— 切换状态
十、鸿蒙集成说明
鸿蒙端架构与 Android 不同:ArkUI 页面渲染 + emitter/AppStorage 双向通信。
| 维度 | Android | 鸿蒙 |
|---|---|---|
| 容器 | WindowManager + WebView | window.createWindow + TYPE_FLOAT |
| 业务 UI | 业务侧 HTML | 业务侧 ArkUI 页面(pages/lw_overlay_page) |
| 原生 → 业务 | evaluateJavascript |
AppStorage.setOrCreate |
| 业务 → 原生 | window.__lwNative.emit/drag |
emitter.emit |
10.1 权限声明
鸿蒙 SYSTEM_FLOAT_WINDOW 是 user_grat 权限,需两步:
步骤 1:主工程 entry/src/main/module.json5 声明权限
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.SYSTEM_FLOAT_WINDOW",
"reason": "$string:reason_float_window",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
}
}
步骤 2:EntryAbility 启动时申请
// entry/src/main/ets/entryability/EntryAbility.ets
import abilityAccessCtrl from '@ohos.abilityAccessCtrl'
import { UIAbility } from '@kit.AbilityKit'
export default class EntryAbility extends UIAbility {
onWindowStageCreate(windowStage) {
const atMgr = abilityAccessCtrl.createAtManager()
atMgr.requestPermissionsFromUser(this.context, ['ohos.permission.SYSTEM_FLOAT_WINDOW'])
}
}
插件层的
requestPermission()仅检查当前授权状态,不会触发系统弹窗。申请必须在 ability 内完成。
10.2 业务 ArkUI 页面模板
主工程必须存在 pages/lw_overlay_page.ets(路径在插件内硬编码,可自行修改)。最小模板:
// entry/src/main/ets/pages/lw_overlay_page.ets
import emitter from '@ohos.events.emitter'
const EVENT_DRAG = 1
const EVENT_TAP = 2
const EVENT_CUSTOM = 3
@Entry
@Component
struct LwOverlayPage {
@StorageLink('lw_overlay_data') @Watch('onDataChange') dataJson: string = '{}'
@StorageLink('lw_overlay_mode') @Watch('onModeChange') mode: string = 'ball'
onDataChange() {
// 解析 this.dataJson 后自定义渲染
}
onModeChange() {
// 根据 this.mode 切换 UI(球/面板)
}
build() {
Stack() {
// TODO 业务自定义 UI
Text(this.mode)
.fontSize(20)
.fontColor(Color.White)
}
.width('100%')
.height('100%')
.backgroundColor(this.mode === 'panel' ? '#CC000000' : '#FF4080FF')
// 点击 → tap
.onClick(() => {
emitter.emit({ eventId: EVENT_TAP, data: {} })
})
// 拖动 → drag
.gesture(
PanGesture()
.onActionUpdate((e: GestureEvent) => {
emitter.emit({
eventId: EVENT_DRAG,
data: { dx: Math.round(e.offsetX), dy: Math.round(e.offsetY) }
})
})
)
}
}
main_pages.json需注册"pages/lw_overlay_page"。
10.3 通信约定
原生 → ArkUI(AppStorage)
| Key | 类型 | 内容 |
|---|---|---|
lw_overlay_data |
string | 业务数据 JSON 字符串。双态下合并 mode 字段后下发 |
lw_overlay_mode |
string | 'ball' 或 'panel',双态切换时同步更新 |
ArkUI → 原生(emitter)
| eventId | 含义 | data 字段 |
|---|---|---|
1 |
drag(增量偏移) | { dx: number, dy: number } |
2 |
tap | {} |
3 |
自定义事件 | { name: string, payload: string }(payload 为 JSON 字符串) |
业务侧自定义事件示例:
emitter.emit({
eventId: 3,
data: {
name: 'collapse',
payload: JSON.stringify({ ts: Date.now() })
}
})
uni 端通过 onOverlayEvent((event, payload) => { ... }) 接收,payload 已被解析为 UTSJSONObject。
10.4 双态模式
双态下 updateOverlay({ mode: 'panel' }) 会自动 win.resize(panelW, panelH),并通过 AppStorage 同步 mode 给 ArkUI 页面。业务侧 @Watch('onModeChange') 内切换 UI。
10.5 注意事项
- 坐标单位:鸿蒙端
initialX/Y、width/height、ballSize/panelSize均为 vp(与 Android 端 dp 概念等价)。 - 拖动接管的区域:业务侧自行决定哪些元素响应 PanGesture(如球态整页拖动,面板态仅头部拖动)。
- 后台保活:插件已内部调
backgroundTaskManager.beginBackgroundRunning申请长时任务,业务侧无需重复处理。 html/htmlUrl/htmlPath在鸿蒙端无效:业务 UI 完全由 ArkUI 页面承载。若必须复用 HTML,在 ArkUI 页面里放 Web 组件自行加载。
十一、打卡悬浮球业务接入(FloatingBall 推送协议)
本章对应本项目 demo(
pages/index/index.vue+hybrid/html/overlay.html)。新项目移植按「11.4 移植清单」操作。
11.1 业务形态
双态悬浮窗,HTML 部署在服务器,App 通过 htmlUrl 加载:
- 小球(60×60dp,纯文字三态):报警(红
#EB4F59)> 打卡中(绿#08A283)> 停卡(橙#FF8A00),随推送自动切换 - 打卡面板(设计稿 480×574px):状态胶囊 + 报警条 + 工单卡(工序名/项目名)+ 工时统计(已打卡 / 今日累计)+ 操作按钮(开始打卡 / 停卡签出 / 一键解除 / 工单列表)
- 无工单(
ClockInfo为空)时空状态:灰底插画 +「暂无可打卡工单」
11.2 推送协议(后端 → App → 悬浮窗)
后端三种消息,App 收到推送后在回调里调 pushOverlay 转发:
| 消息 | 触发时机 | 字段 |
|---|---|---|
FloatingBallInit |
初始化连接(全量) | UserId, IsClock, IsSOS, ClockInfo, SOSInfo |
FloatingBallSOS |
产生报警 | UserId, IsSOS, SOSInfo |
FloatingBallClock |
打卡变更 | UserId, IsClock, ClockInfo |
ClockInfo:{ PrjId, PrjName, PlanId, PlanName, WorkId, Clocktime, Total }(Total = 本工单外今日累计分钟;Clocktime 支持秒/毫秒时间戳与 yyyy-MM-dd HH:mm:ss)
SOSInfo:{ HelmetId, Id, SOSName, CreateTime }
import { pushOverlay } from '@/uni_modules/lw-overlay'
// 推送 SDK 回调内转发(示例)
pushOverlay('FloatingBallInit', {
UserId: 'U10086',
IsClock: true,
IsSOS: false,
ClockInfo: {
PrjId: 'P001', PrjName: '盛宏酒业办公楼改造项目',
PlanId: 'G002', PlanName: '中央空调安装',
WorkId: 'W2001', Clocktime: Date.now() - 9065000, Total: 240
},
SOSInfo: null
})
HTML 端消费入口:window.__lw_overlay.push(name, msg)(pushOverlay 内部自动转发,msg 对象/JSON 字符串均可)。
11.3 HTML → App 事件(onOverlayEvent 接收)
| event | 触发按钮 | payload | App 侧动作 |
|---|---|---|---|
collapse |
面板关闭 | { ts } |
插件已原生折叠回小球,无需处理 |
orderList |
工单列表 | { ts, UserId } |
跳工单列表页 |
startClock |
开始打卡 | { ts, WorkId, PrjId, PlanId } |
调后端打卡接口 |
endClock |
停卡签出 | { ts, WorkId, PrjId, PlanId, workedSec } |
调后端签出接口 |
releaseAlarm |
一键解除 | { ts, HelmetId, Id } |
调报警解除接口 |
打卡/报警状态以推送为准:按钮点击只做 UI 临时同步 + 事件上报,等
FloatingBallClock/FloatingBallSOS推送校正终态。
11.4 移植清单(demo → 新项目)
- 拷贝
uni_modules/lw-overlay/整目录到新项目uni_modules/ overlay.html部署到新项目对应服务器(demo 地址:http://47.101.205.245:8026/overlay.html),HTML 内初始 mock 数据可清空- 新项目页面参考
pages/index/index.vue:onLoad注册onOverlayTap(球点击展开面板)、onOverlayEvent(处理 11.3 事件)showOverlay参数照抄 demo:ballSize: {width:60,height:60}、panelSize、initialMode:'ball'、htmlUrl、style: { backgroundColor:'transparent' }(勿传 elevation,面板态会出现灰色投影)- 推送回调内
pushOverlay(name, msg)转发三种消息
- 重新制作自定义基座(UTS/Kotlin 原生代码变更不支持热更新)
11.5 插件内置行为(本业务相关)
collapse:原生直接折叠回小球,页面销毁/未监听时依然生效- 展开居中:球态切面板态时面板自动屏幕居中并 clamp 在屏幕内;收回球态时球恢复展开前位置
- 拖动边界:球态原生拖动与面板态
drag桥接均已 clamp 在屏幕范围内,不会拖出被裁 style.elevation:仅球态生效,面板态自动清零
十二、目录结构
uni_modules/lw-overlay/
├── package.json
├── readme.md
└── utssdk/
├── interface.uts # 统一类型契约
├── unierror.uts # 错误码 & OverlayFailImpl
├── app-android/
│ ├── index.uts # UTS 桥接层
│ ├── hybrid.kt # OverlayService + WebView + TouchFrameLayout + LwJsBridge
│ ├── AndroidManifest.xml # 权限 + service + cleartextTraffic
│ └── config.json
└── app-harmony/
└── index.uts # 鸿蒙端:window + ArkUI + emitter + AppStorage
十三、注意事项
- 同进程单例:插件用静态字段持有状态,同一时刻只允许一个悬浮窗,再次 show 之前必须 hide。
- HTML 内存:
detachOverlay会主动 destroy WebView 并清除引用,避免内存泄漏。 - dp / px:所有对外 API 用 dp,内部按屏幕 density 自动转 px。
- JS 桥接安全:
@JavascriptInterface仅暴露emit/drag两个方法,4.2+ 不受反射漏洞影响。生产环境应校验htmlUrl域名,避免恶意页面调用__lwNative。 - WebView 缓存:
cacheMode = LOAD_NO_CACHE,每次 loadUrl 都拉取最新 HTML。 - 隐私合规:
AppHookProxy.onCreate校验isPrivacyAgree(),需配合 uni-app 隐私弹窗使用。

收藏人数:
购买源码授权版(
试用
使用 HBuilderX 导入示例项目
赞赏(0)
下载 707
赞赏 7
下载 12607838
赞赏 1949
赞赏
京公网安备:11010802035340号