From d3dab30d48abcb2a4d54254bcfdd913e2388d79e Mon Sep 17 00:00:00 2001 From: chenghuan Date: Tue, 23 Jun 2026 17:07:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=AE=89=E5=8D=93?= =?UTF-8?q?=E8=87=AA=E6=88=AA=E5=9B=BEuts=E6=8F=92=E4=BB=B6=E3=80=81?= =?UTF-8?q?=E4=BF=AE=E6=94=B9ios=E5=85=81=E8=AE=B8=E6=88=AA=E5=B1=8F?= =?UTF-8?q?=E7=A6=81=E6=AD=A2=E5=BD=95=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manifest.json | 4 +- package-lock.json | 10 +- package.json | 2 +- pages/homePage/index/index.vue | 10 +- .../uni-usercapturescreen/changelog.md | 2 + uni_modules/uni-usercapturescreen/index.d.ts | 6 + .../uni-usercapturescreen/package.json | 2 +- .../utssdk/app-ios/index.uts | 269 +++++++++++++++--- .../utssdk/interface.uts | 6 + uni_modules/yb-screen-capture/index.d.ts | 29 ++ uni_modules/yb-screen-capture/package.json | 85 ++++++ uni_modules/yb-screen-capture/readme.md | 22 ++ .../utssdk/app-android/index.uts | 127 +++++++++ .../yb-screen-capture/utssdk/interface.uts | 73 +++++ 14 files changed, 590 insertions(+), 57 deletions(-) create mode 100644 uni_modules/yb-screen-capture/index.d.ts create mode 100644 uni_modules/yb-screen-capture/package.json create mode 100644 uni_modules/yb-screen-capture/readme.md create mode 100644 uni_modules/yb-screen-capture/utssdk/app-android/index.uts create mode 100644 uni_modules/yb-screen-capture/utssdk/interface.uts diff --git a/manifest.json b/manifest.json index 3e2dd3e..a8db16a 100644 --- a/manifest.json +++ b/manifest.json @@ -13,8 +13,8 @@ "src" : "图片路径" } ], - "versionName" : "1.0.65", - "versionCode" : 1065, + "versionName" : "1.0.66", + "versionCode" : 1066, "app-plus" : { "nvueCompiler" : "weex", "compatible" : { diff --git a/package-lock.json b/package-lock.json index bb37498..c7c8a3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "3.4.5", "license": "MIT", "dependencies": { - "edu-core": "git+https://git.nuttyreading.com/chenghuan/edu-core.git#v1.0.14", + "edu-core": "git+https://git.nuttyreading.com/chenghuan/edu-core.git#v1.0.15", "jquery": "^3.7.1", "tcplayer.js": "^5.1.0" }, @@ -68,8 +68,8 @@ "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" }, "node_modules/edu-core": { - "version": "1.0.14", - "resolved": "git+https://git.nuttyreading.com/chenghuan/edu-core.git#eb9eccdc3d281fe84127a93e019650512859ff0c", + "version": "1.0.15", + "resolved": "git+https://git.nuttyreading.com/chenghuan/edu-core.git#df2306c058445de44914622bf56459f76247f5b1", "license": "ISC" }, "node_modules/es5-shim": { @@ -393,8 +393,8 @@ "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" }, "edu-core": { - "version": "git+https://git.nuttyreading.com/chenghuan/edu-core.git#eb9eccdc3d281fe84127a93e019650512859ff0c", - "from": "edu-core@git+https://git.nuttyreading.com/chenghuan/edu-core.git#v1.0.14" + "version": "git+https://git.nuttyreading.com/chenghuan/edu-core.git#df2306c058445de44914622bf56459f76247f5b1", + "from": "edu-core@git+https://git.nuttyreading.com/chenghuan/edu-core.git#v1.0.15" }, "es5-shim": { "version": "4.6.7", diff --git a/package.json b/package.json index d1eaa19..3ac6a62 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ }, "homepage": "https://github.com/dcloudio/hello-uniapp#readme", "dependencies": { - "edu-core": "git+https://git.nuttyreading.com/chenghuan/edu-core.git#v1.0.14", + "edu-core": "git+https://git.nuttyreading.com/chenghuan/edu-core.git#v1.0.15", "jquery": "^3.7.1", "tcplayer.js": "^5.1.0" }, diff --git a/pages/homePage/index/index.vue b/pages/homePage/index/index.vue index b7d7d40..6fd4479 100644 --- a/pages/homePage/index/index.vue +++ b/pages/homePage/index/index.vue @@ -356,11 +356,11 @@ export default { }, onReady() {}, onLoad() { - if (plus.os.name == "Android") { - this.isShowTaihu=true - }else{ - this.isShowTaihu=false - } + if (plus.os.name == "Android") { + this.isShowTaihu=true + }else{ + this.isShowTaihu=false + } this.$nextTick(() => { this.getAdvertisement(); }); diff --git a/uni_modules/uni-usercapturescreen/changelog.md b/uni_modules/uni-usercapturescreen/changelog.md index 6bb238a..ef8c2eb 100644 --- a/uni_modules/uni-usercapturescreen/changelog.md +++ b/uni_modules/uni-usercapturescreen/changelog.md @@ -1,3 +1,5 @@ +## 1.0.7(2026-06-16) +- 新增 iOS setUserCaptureScreen 支持 antiRecordOnly 参数:enable 为 false 且 antiRecordOnly 为 true 时,仅禁止录屏/投屏,允许截屏。实现方式为进入时提前挂载安全层(UITextField.isSecureTextEntry,默认不保护、保留 field 实例),通过 UIScreen.isCaptured 监听录屏/投屏,仅在录屏期间翻转 isSecureTextEntry——实时显示正常、录制到的文件为黑屏,平时不影响截屏(录屏进行中截屏也为黑)。Android/鸿蒙忽略该参数,仍为截屏+录屏一起禁止。 ## 1.0.6(2024-11-22) - 修复 HarmonyOS Next 上调用 setUserCaptureScreen 报错的 Bug ## 1.0.5(2024-10-14) diff --git a/uni_modules/uni-usercapturescreen/index.d.ts b/uni_modules/uni-usercapturescreen/index.d.ts index dbb3f92..0eef976 100644 --- a/uni_modules/uni-usercapturescreen/index.d.ts +++ b/uni_modules/uni-usercapturescreen/index.d.ts @@ -63,6 +63,12 @@ declare namespace UniNamespace { */ enable : boolean; /** + * 仅 iOS 生效。仅在 enable 为 false 时有意义: + * true: 仅禁止录屏/投屏(录屏时画面被遮挡),允许截屏; + * false/不传: 截屏与录屏一起禁止(默认行为)。 + */ + antiRecordOnly ?: boolean; + /** * 接口调用成功的回调函数 */ // success : SetUserCaptureScreenSuccessCallback | null, diff --git a/uni_modules/uni-usercapturescreen/package.json b/uni_modules/uni-usercapturescreen/package.json index d27fbd6..1384482 100644 --- a/uni_modules/uni-usercapturescreen/package.json +++ b/uni_modules/uni-usercapturescreen/package.json @@ -1,7 +1,7 @@ { "id": "uni-usercapturescreen", "displayName": "uni-usercapturescreen", - "version": "1.0.6", + "version": "1.0.7", "description": "用户主动截屏事件监听", "keywords": [ "截屏" diff --git a/uni_modules/uni-usercapturescreen/utssdk/app-ios/index.uts b/uni_modules/uni-usercapturescreen/utssdk/app-ios/index.uts index 17eec4b..a024811 100644 --- a/uni_modules/uni-usercapturescreen/utssdk/app-ios/index.uts +++ b/uni_modules/uni-usercapturescreen/utssdk/app-ios/index.uts @@ -12,6 +12,16 @@ class CaptureScreenTool { static listener : UserCaptureScreenCallback | null; static secureView : UIView | null; + // ===== 仅防录屏(动态切换)相关状态 ===== + // 保留 UITextField,使 isSecureTextEntry 可在录屏开始/结束时反复切换 + static secureField : UITextField | null; + // field 的安全画布(已挂入视图层级,承载 App 根视图) + static secureCanvas : UIView | null; + // 被安全画布包裹的 App 根视图(用于卸载时还原) + static secureRootView : UIView | null; + // 是否已为"仅防录屏"挂载安全层 + static recordWrapped : boolean = false; + // 监听截屏 static listenCaptureScreen(callback : UserCaptureScreenCallback | null) { this.listener = callback @@ -51,28 +61,38 @@ class CaptureScreenTool { return null } + // 应用防截屏遮罩(secureView:实时显示正常,但被截屏/录屏捕获到的内容为黑屏) + // 不触发回调,供内部复用;注意:需在主线程调用 + static applySecureViewInternal() { + if (this.secureView != null) { + // 已应用,避免重复包裹 + return + } + let secureView = this.createSecureView() + let window = UTSiOS.getKeyWindow() + let rootView = window.rootViewController == null ? null : window.rootViewController!.view + if (secureView != null && rootView != null) { + let rootSuperview = rootView!.superview + if (rootSuperview != null) { + this.secureView = secureView + rootSuperview!.addSubview(secureView!) + rootView!.removeFromSuperview() + secureView!.addSubview(rootView!) + // secureView 充满父视图并随父视图宽高自适应(横竖屏、全屏切换时自动跟随) + secureView!.frame = rootSuperview!.bounds + secureView!.autoresizingMask = [UIView.AutoresizingMask.flexibleWidth, UIView.AutoresizingMask.flexibleHeight] + // rootView 充满 secureView 并自适应,避免全屏时被固定在竖屏尺寸而缩到角落 + rootView!.frame = secureView!.bounds + rootView!.autoresizingMask = [UIView.AutoresizingMask.flexibleWidth, UIView.AutoresizingMask.flexibleHeight] + } + } + } + // 开启防截屏 static onAntiScreenshot(option : SetUserCaptureScreenOptions) { // uts方法默认会在子线程中执行,涉及 UI 操作必须在主线程中运行,通过 DispatchQueue.main.async 方法可将代码在主线程中运行 DispatchQueue.main.async(execute = () : void => { - let secureView = this.createSecureView() - let window = UTSiOS.getKeyWindow() - let rootView = window.rootViewController == null ? null : window.rootViewController!.view - if (secureView != null && rootView != null) { - let rootSuperview = rootView!.superview - if (rootSuperview != null) { - this.secureView = secureView - rootSuperview!.addSubview(secureView!) - rootView!.removeFromSuperview() - secureView!.addSubview(rootView!) - // secureView 充满父视图并随父视图宽高自适应(横竖屏、全屏切换时自动跟随) - secureView!.frame = rootSuperview!.bounds - secureView!.autoresizingMask = [UIView.AutoresizingMask.flexibleWidth, UIView.AutoresizingMask.flexibleHeight] - // rootView 充满 secureView 并自适应,避免全屏时被固定在竖屏尺寸而缩到角落 - rootView!.frame = secureView!.bounds - rootView!.autoresizingMask = [UIView.AutoresizingMask.flexibleWidth, UIView.AutoresizingMask.flexibleHeight] - } - } + this.applySecureViewInternal() let res: SetUserCaptureScreenSuccess = { } option.success?.(res) @@ -80,21 +100,107 @@ class CaptureScreenTool { }) } + // ===== 仅防录屏:提前挂载安全层 + 翻转 isSecureTextEntry ===== + + // 提前挂载安全层(应在录屏开始前调用,例如进入页面时)。 + // 挂载后默认不保护(isSecureTextEntry = false)→ 截屏正常、实时正常。 + // 注意:需在主线程调用。 + static setupRecordSecureWrapper() { + if (this.recordWrapped) { + return + } + let field = new UITextField(frame = CGRect.zero) + field.isSecureTextEntry = true + // 15.1 系统 secureView 不可用;拿不到安全画布则放弃 + if (field.subviews.length == 0 || UIDevice.current.systemVersion == '15.1') { + return + } + let canvas = field.subviews[0] + canvas.subviews.forEach((item) => { + item.removeFromSuperview() + }) + canvas.isUserInteractionEnabled = true + + let window = UTSiOS.getKeyWindow() + let rootView = window.rootViewController == null ? null : window.rootViewController!.view + if (rootView == null) { + return + } + let rootSuperview = rootView!.superview + if (rootSuperview == null) { + return + } + + // 把安全画布插入原层级,并把 App 根视图移入画布 + rootSuperview!.addSubview(canvas) + rootView!.removeFromSuperview() + canvas.addSubview(rootView!) + canvas.frame = rootSuperview!.bounds + canvas.autoresizingMask = [UIView.AutoresizingMask.flexibleWidth, UIView.AutoresizingMask.flexibleHeight] + rootView!.frame = canvas.bounds + rootView!.autoresizingMask = [UIView.AutoresizingMask.flexibleWidth, UIView.AutoresizingMask.flexibleHeight] + + // 关键:保留 field,后续才能切换 isSecureTextEntry + this.secureField = field + this.secureCanvas = canvas + this.secureRootView = rootView + this.recordWrapped = true + + // 默认不保护:允许截屏、实时正常 + field.isSecureTextEntry = false + } + + // 切换录屏保护:true=保护(录屏文件黑、实时正常、此刻截屏也黑);false=不保护(允许截屏) + // 注意:需在主线程调用。 + static setRecordSecure(secure : boolean) { + if (this.secureField != null) { + this.secureField!.isSecureTextEntry = secure + } + } + + // 卸载安全层,恢复原始视图层级。注意:需在主线程调用。 + static teardownRecordSecureWrapper() { + if (!this.recordWrapped) { + return + } + if (this.secureField != null) { + this.secureField!.isSecureTextEntry = false + } + if (this.secureCanvas != null && this.secureRootView != null) { + let rootSuperview = this.secureCanvas!.superview + if (rootSuperview != null) { + rootSuperview!.addSubview(this.secureRootView!) + this.secureRootView!.frame = rootSuperview!.bounds + this.secureCanvas!.removeFromSuperview() + } + } + this.secureField = null + this.secureCanvas = null + this.secureRootView = null + this.recordWrapped = false + } + + // 移除防截屏遮罩视图(不触发回调,供内部复用) + // 注意:需在主线程调用 + static removeSecureViewInternal() { + if (this.secureView != null) { + let window = UTSiOS.getKeyWindow() + let rootView = window.rootViewController == null ? null : window.rootViewController!.view + if (rootView != null && this.secureView!.superview != null) { + let rootSuperview = this.secureView!.superview + if (rootSuperview != null) { + rootSuperview!.addSubview(rootView!) + this.secureView!.removeFromSuperview() + } + } + this.secureView = null + } + } + // 关闭防截屏 static offAntiScreenshot(option : SetUserCaptureScreenOptions) { DispatchQueue.main.async(execute = () : void => { - if (this.secureView != null) { - let window = UTSiOS.getKeyWindow() - let rootView = window.rootViewController == null ? null : window.rootViewController!.view - if (rootView != null && this.secureView!.superview != null) { - let rootSuperview = this.secureView!.superview - if (rootSuperview != null) { - rootSuperview!.addSubview(rootView!) - this.secureView!.removeFromSuperview() - } - } - this.secureView = null - } + this.removeSecureViewInternal() let res: SetUserCaptureScreenSuccess = { } option.success?.(res) @@ -103,6 +209,58 @@ class CaptureScreenTool { } } +/** + * 防录屏工具类:基于 UIScreen.isCaptured 监听录屏/投屏状态,动态切换安全层。 + * 截屏不会触发 isCaptured,因此可做到"允许截屏、仅在录屏时保护"。 + * + * 实现要点(避免实时画面变黑): + * 1) 进入时(录屏开始前)就提前挂好安全层,且默认不保护 → 截屏正常、实时正常; + * 2) 仅在录屏期间翻转 isSecureTextEntry = true → 录屏文件黑、实时正常、此刻截屏也黑; + * 3) 录屏结束翻回 false → 截屏恢复正常。 + */ +class ScreenRecordTool { + // 是否已注册监听 + static monitoring : boolean = false; + + // 开启防录屏:提前挂载安全层并监听录屏状态变化 + static onAntiScreenRecord() { + DispatchQueue.main.async(execute = () : void => { + // 关键:录屏开始前就把安全层挂好(默认不保护) + CaptureScreenTool.setupRecordSecureWrapper() + if (!this.monitoring) { + this.monitoring = true + const method = Selector("screenCaptureDidChange") + NotificationCenter.default.addObserver(this, selector = method, name = UIScreen.capturedDidChangeNotification, object = null) + } + // 进入时若已经在录屏,立即按当前状态保护 + this.applyIfNeeded() + }) + } + + // 录屏状态变化回调 + @objc static screenCaptureDidChange() { + DispatchQueue.main.async(execute = () : void => { + this.applyIfNeeded() + }) + } + + // 根据当前录屏状态翻转安全层开关 + static applyIfNeeded() { + CaptureScreenTool.setRecordSecure(UIScreen.main.isCaptured) + } + + // 关闭防录屏:移除监听并卸载安全层 + static offAntiScreenRecord() { + DispatchQueue.main.async(execute = () : void => { + if (this.monitoring) { + NotificationCenter.default.removeObserver(this, name = UIScreen.capturedDidChangeNotification, object = null) + this.monitoring = false + } + CaptureScreenTool.teardownRecordSecureWrapper() + }) + } +} + /** * 开启截图监听 */ @@ -121,21 +279,46 @@ export const offUserCaptureScreen : OffUserCaptureScreen = function (callback : * 开启/关闭防截屏 */ export const setUserCaptureScreen : SetUserCaptureScreen = function (options : SetUserCaptureScreenOptions) { - if (UIDevice.current.systemVersion < "13.0") { - let res = new SetUserCaptureScreenFailImpl(12001) - options.fail?.(res); - options.complete?.(res); + const systemVersion = UIDevice.current.systemVersion - } else if (UIDevice.current.systemVersion == "15.1") { - let res = new SetUserCaptureScreenFailImpl(12010) - options.fail?.(res); - options.complete?.(res); - } else { - if (options.enable == true) { - CaptureScreenTool.offAntiScreenshot(options) - } else { - CaptureScreenTool.onAntiScreenshot(options) + if (options.enable == true) { + // 允许截屏与录屏:无条件清理录屏遮罩,再移除防截屏遮罩。 + // 清理动作不受版本限制,避免遗留监听/遮罩。 + ScreenRecordTool.offAntiScreenRecord() + CaptureScreenTool.offAntiScreenshot(options) + return + } + + if (options.antiRecordOnly == true) { + // 仅禁止录屏、允许截屏:进入页面时提前挂好安全层(默认不保护), + // 用 UIScreen.isCaptured(iOS 11+)监听录屏/投屏,仅在录屏期间翻转 isSecureTextEntry + // —— 实时显示正常、录到的画面为黑屏,且平时不影响截屏。 + // 注意:secureView 在 iOS 15.1 上不可用,该版本录屏保护会失效(属系统已知问题)。 + if (systemVersion < "11.0") { + let res = new SetUserCaptureScreenFailImpl(12001) + options.fail?.(res) + options.complete?.(res) + return } + ScreenRecordTool.onAntiScreenRecord() + let res: SetUserCaptureScreenSuccess = { + } + options.success?.(res) + options.complete?.(res) + return + } + + // 默认:截屏与录屏一起禁止(secureView 方案,受系统版本限制) + if (systemVersion < "13.0") { + let res = new SetUserCaptureScreenFailImpl(12001) + options.fail?.(res) + options.complete?.(res) + } else if (systemVersion == "15.1") { + let res = new SetUserCaptureScreenFailImpl(12010) + options.fail?.(res) + options.complete?.(res) + } else { + CaptureScreenTool.onAntiScreenshot(options) } } diff --git a/uni_modules/uni-usercapturescreen/utssdk/interface.uts b/uni_modules/uni-usercapturescreen/utssdk/interface.uts index b4abf42..0d37a18 100644 --- a/uni_modules/uni-usercapturescreen/utssdk/interface.uts +++ b/uni_modules/uni-usercapturescreen/utssdk/interface.uts @@ -49,6 +49,12 @@ */ enable : boolean; /** + * 仅 iOS 生效。仅在 enable 为 false 时有意义: + * true: 仅禁止录屏/投屏(录屏时画面被遮挡),允许截屏; + * false/不传: 截屏与录屏一起禁止(默认行为)。 + */ + antiRecordOnly ?: boolean; + /** * 接口调用成功的回调函数 */ // success : SetUserCaptureScreenSuccessCallback | null, diff --git a/uni_modules/yb-screen-capture/index.d.ts b/uni_modules/yb-screen-capture/index.d.ts new file mode 100644 index 0000000..27d645f --- /dev/null +++ b/uni_modules/yb-screen-capture/index.d.ts @@ -0,0 +1,29 @@ +export type CaptureScreenSuccess = { + /** + * 截图结果,格式为 data URL:data:image/jpeg;base64,xxxx + */ + base64 : string +} + +export type CaptureScreenSuccessCallback = (res : CaptureScreenSuccess) => void + +export type CaptureScreenFail = { + errCode : number, + errMsg : string +} + +export type CaptureScreenFailCallback = (res : CaptureScreenFail) => void + +export type CaptureScreenCompleteCallback = (res : any) => void + +export type CaptureScreenOptions = { + success ?: CaptureScreenSuccessCallback, + fail ?: CaptureScreenFailCallback, + complete ?: CaptureScreenCompleteCallback +} + +/** + * 截取当前应用窗口画面(含视频等硬件加速渲染内容),通过 base64 返回。 + * 仅 App-Android 生效,基于 Android PixelCopy 实现。 + */ +export declare const captureScreen : (options : CaptureScreenOptions) => void diff --git a/uni_modules/yb-screen-capture/package.json b/uni_modules/yb-screen-capture/package.json new file mode 100644 index 0000000..64fab2c --- /dev/null +++ b/uni_modules/yb-screen-capture/package.json @@ -0,0 +1,85 @@ +{ + "id": "yb-screen-capture", + "displayName": "yb-screen-capture", + "version": "1.0.0", + "description": "截取当前应用窗口(含硬件加速渲染的视频画面),返回 base64", + "keywords": [ + "截图", + "截屏", + "PixelCopy" + ], + "repository": "", + "engines": { + "HBuilderX": "^3.7.7" + }, + "dcloudext": { + "type": "uts", + "sale": { + "regular": { + "price": "0.00" + }, + "sourcecode": { + "price": "0.00" + } + }, + "contact": { + "qq": "" + }, + "declaration": { + "ads": "无", + "data": "插件不采集任何数据", + "permissions": "无" + }, + "npmurl": "" + }, + "uni_modules": { + "dependencies": [], + "encrypt": [], + "platforms": { + "cloud": { + "tcb": "y", + "aliyun": "y", + "alipay": "n" + }, + "client": { + "Vue": { + "vue2": "y", + "vue3": "y" + }, + "App": { + "app-android": "y", + "app-ios": "n", + "app-harmony": "n" + }, + "H5-mobile": { + "Safari": "n", + "Android Browser": "n", + "微信浏览器(Android)": "n", + "QQ浏览器(Android)": "n" + }, + "H5-pc": { + "Chrome": "n", + "IE": "n", + "Edge": "n", + "Firefox": "n", + "Safari": "n" + }, + "小程序": { + "微信": "n", + "阿里": "n", + "百度": "n", + "字节跳动": "n", + "QQ": "n", + "钉钉": "n", + "快手": "n", + "飞书": "n", + "京东": "n" + }, + "快应用": { + "华为": "n", + "联盟": "n" + } + } + } + } +} diff --git a/uni_modules/yb-screen-capture/readme.md b/uni_modules/yb-screen-capture/readme.md new file mode 100644 index 0000000..63e18f4 --- /dev/null +++ b/uni_modules/yb-screen-capture/readme.md @@ -0,0 +1,22 @@ +# yb-screen-capture + +截取当前应用窗口画面(含视频等硬件加速渲染内容),通过 base64 返回。 + +- 仅 **App-Android** 生效,基于 Android `PixelCopy` 实现(Android 8.0 及以上能正确截取视频画面;低于 8.0 降级为 View 截图,视频区域可能为黑屏)。 +- 截图范围为「当前屏幕可见区域」,不是长页面截图。 + +## 使用 + +```js +uni.captureScreen({ + success: (res) => { + // res.base64 形如:data:image/jpeg;base64,xxxx + console.log(res.base64) + }, + fail: (err) => { + console.error(err.errCode, err.errMsg) + } +}) +``` + +> 注意:UTS 插件需要打自定义基座(标准基座无法运行)才能调试/运行。 diff --git a/uni_modules/yb-screen-capture/utssdk/app-android/index.uts b/uni_modules/yb-screen-capture/utssdk/app-android/index.uts new file mode 100644 index 0000000..904acad --- /dev/null +++ b/uni_modules/yb-screen-capture/utssdk/app-android/index.uts @@ -0,0 +1,127 @@ +import { UTSAndroid } from "io.dcloud.uts"; +import PixelCopy from "android.view.PixelCopy"; +import OnPixelCopyFinishedListener from "android.view.PixelCopy.OnPixelCopyFinishedListener"; +import Bitmap from "android.graphics.Bitmap"; +import Canvas from "android.graphics.Canvas"; +import Handler from "android.os.Handler"; +import Looper from "android.os.Looper"; +import Build from "android.os.Build"; +import ByteArrayOutputStream from "java.io.ByteArrayOutputStream"; +import Base64 from "android.util.Base64"; +import { + CaptureScreen, + CaptureScreenOptions, + CaptureScreenSuccess, + CaptureScreenFail +} from "../interface.uts"; + +/** + * 将 Bitmap 压缩为 JPEG 并编码为 data URL 形式的 base64 + */ +function encodeBitmapToBase64(bitmap : Bitmap) : string { + const baos = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos); + const bytes = baos.toByteArray(); + const base64 = Base64.encodeToString(bytes, Base64.NO_WRAP); + bitmap.recycle(); + return "data:image/jpeg;base64," + base64; +} + +/** + * PixelCopy 完成监听器 + */ +class PixelCopyListener extends OnPixelCopyFinishedListener { + + private bitmap : Bitmap; + private options : CaptureScreenOptions; + + constructor(bitmap : Bitmap, options : CaptureScreenOptions) { + super(); + this.bitmap = bitmap; + this.options = options; + } + + override onPixelCopyFinished(copyResult : Int) : void { + if (copyResult == PixelCopy.SUCCESS) { + const base64 = encodeBitmapToBase64(this.bitmap); + const res : CaptureScreenSuccess = { base64: base64 }; + this.options.success?.(res); + this.options.complete?.(res); + } else { + this.bitmap.recycle(); + const res : CaptureScreenFail = { + errCode: 12010, + errMsg: "captureScreen:fail PixelCopy copyResult=" + copyResult + }; + this.options.fail?.(res); + this.options.complete?.(res); + } + } +} + +/** + * 在 UI 线程执行实际截图 + */ +class CaptureRunnable extends Runnable { + + private options : CaptureScreenOptions; + + constructor(options : CaptureScreenOptions) { + super(); + this.options = options; + } + + override run() : void { + try { + const activity = UTSAndroid.getUniActivity(); + if (activity == null) { + const res : CaptureScreenFail = { errCode: 12010, errMsg: "captureScreen:fail activity is null" }; + this.options.fail?.(res); + this.options.complete?.(res); + return; + } + // 注意:UTS 中 getWindow() 返回可空类型,必须用 ! 断言,否则编译失败 + const window = activity!.getWindow()!; + const decorView = window.getDecorView()!; + const width = decorView.getWidth(); + const height = decorView.getHeight(); + if (width <= 0 || height <= 0) { + const res : CaptureScreenFail = { errCode: 12010, errMsg: "captureScreen:fail invalid window size" }; + this.options.fail?.(res); + this.options.complete?.(res); + return; + } + + const bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Android 8.0+:使用 PixelCopy 可以正确截取 SurfaceView/视频等硬件加速画面 + const handler = new Handler(Looper.getMainLooper()); + PixelCopy.request(window, bitmap, new PixelCopyListener(bitmap, this.options), handler); + } else { + // 低版本降级:仅能截取 View 层级内容,视频区域可能为黑屏 + const canvas = new Canvas(bitmap); + decorView.draw(canvas); + const base64 = encodeBitmapToBase64(bitmap); + const res : CaptureScreenSuccess = { base64: base64 }; + this.options.success?.(res); + this.options.complete?.(res); + } + } catch (e) { + const res : CaptureScreenFail = { errCode: 12010, errMsg: "captureScreen:fail " + e.toString() }; + this.options.fail?.(res); + this.options.complete?.(res); + } + } +} + +export const captureScreen : CaptureScreen = function (options : CaptureScreenOptions) { + const activity = UTSAndroid.getUniActivity(); + if (activity == null) { + const res : CaptureScreenFail = { errCode: 12010, errMsg: "captureScreen:fail activity is null" }; + options.fail?.(res); + options.complete?.(res); + return; + } + activity!.runOnUiThread(new CaptureRunnable(options)); +} diff --git a/uni_modules/yb-screen-capture/utssdk/interface.uts b/uni_modules/yb-screen-capture/utssdk/interface.uts new file mode 100644 index 0000000..e829e8f --- /dev/null +++ b/uni_modules/yb-screen-capture/utssdk/interface.uts @@ -0,0 +1,73 @@ +/** + * uni.captureScreen 成功回调参数 + */ +export type CaptureScreenSuccess = { + /** + * 截图结果,格式为 data URL:data:image/jpeg;base64,xxxx + */ + base64 : string +} + +/** + * uni.captureScreen 成功回调函数定义 + */ +export type CaptureScreenSuccessCallback = (res : CaptureScreenSuccess) => void + +/** + * uni.captureScreen 失败回调参数 + */ +export type CaptureScreenFail = { + errCode : number, + errMsg : string +} + +/** + * uni.captureScreen 失败回调函数定义 + */ +export type CaptureScreenFailCallback = (res : CaptureScreenFail) => void + +/** + * uni.captureScreen 完成回调函数定义(成功、失败都会执行) + */ +export type CaptureScreenCompleteCallback = (res : any) => void + +/** + * uni.captureScreen 参数 + */ +export type CaptureScreenOptions = { + /** + * 接口调用成功的回调函数 + */ + success ?: CaptureScreenSuccessCallback, + /** + * 接口调用失败的回调函数 + */ + fail ?: CaptureScreenFailCallback, + /** + * 接口调用结束的回调函数(调用成功、失败都会执行) + */ + complete ?: CaptureScreenCompleteCallback +} + +export type CaptureScreen = (options : CaptureScreenOptions) => void + +export interface Uni { + /** + * 截取当前应用窗口画面(含视频等硬件加速渲染内容),通过 base64 返回。 + * 仅 App-Android 生效,基于 Android PixelCopy 实现。 + * + * @param {CaptureScreenOptions} options + * @uniPlatform { + * "app": { + * "android": { + * "osVer": "8.0", + * "uniVer": "3.7.7", + * "unixVer": "3.9.0" + * } + * } + * } + * @uniVersion 3.7.7 + * @uniVueVersion 2,3 + */ + captureScreen(options : CaptureScreenOptions) : void +}