更新记录

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 双态:球 + 展开面板

双态模式 = 同时提供 ballSizepanelSize,通过 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', '{}') → 同时触发 onOverlayTap
  • emit('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" + property user 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/Ywidth/heightballSize/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 → 新项目)

  1. 拷贝 uni_modules/lw-overlay/ 整目录到新项目 uni_modules/
  2. overlay.html 部署到新项目对应服务器(demo 地址:http://47.101.205.245:8026/overlay.html),HTML 内初始 mock 数据可清空
  3. 新项目页面参考 pages/index/index.vue
    • onLoad 注册 onOverlayTap(球点击展开面板)、onOverlayEvent(处理 11.3 事件)
    • showOverlay 参数照抄 demo:ballSize: {width:60,height:60}panelSizeinitialMode:'ball'htmlUrlstyle: { backgroundColor:'transparent' }勿传 elevation,面板态会出现灰色投影)
    • 推送回调内 pushOverlay(name, msg) 转发三种消息
  4. 重新制作自定义基座(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

十三、注意事项

  1. 同进程单例:插件用静态字段持有状态,同一时刻只允许一个悬浮窗,再次 show 之前必须 hide。
  2. HTML 内存detachOverlay 会主动 destroy WebView 并清除引用,避免内存泄漏。
  3. dp / px:所有对外 API 用 dp,内部按屏幕 density 自动转 px。
  4. JS 桥接安全@JavascriptInterface 仅暴露 emit / drag 两个方法,4.2+ 不受反射漏洞影响。生产环境应校验 htmlUrl 域名,避免恶意页面调用 __lwNative
  5. WebView 缓存cacheMode = LOAD_NO_CACHE,每次 loadUrl 都拉取最新 HTML。
  6. 隐私合规AppHookProxy.onCreate 校验 isPrivacyAgree(),需配合 uni-app 隐私弹窗使用。

隐私、权限声明

1. 本插件需要申请的系统权限列表:

[object Object]

2. 本插件采集的数据、发送的服务器地址、以及数据用途说明:

插件不采集任何数据

3. 本插件是否包含广告,如包含需详细说明广告表达方式、展示频率:

[object Object]

暂无用户评论。