• [技术干货] 开发者技术支持-权限申请失败解决
    1.1 问题说明在鸿蒙应用开发中,基于Stage模型开发需要访问设备存储(读取本地图片)的功能时,出现权限申请失败的问题。具体表现为:调用权限申请接口后,未弹出权限授权弹窗,日志打印“permission request failed, code: 202”;手动在系统设置中为应用开启存储权限后,应用仍无法读取本地文件,提示“no permission to access storage”;该问题在API 9真机(鸿蒙4.0系统)中稳定复现,模拟器中部分场景可正常申请,且仅存储权限出现异常,其他基础权限(如网络权限)可正常获取。1.2 原因分析结合鸿蒙权限管理机制及Stage模型权限申请规范,从4个核心维度分析原因:权限配置缺失:未在应用配置文件(module.json5)中声明存储权限,或权限声明格式错误、权限等级设置不当,导致系统无法识别应用的权限需求,拒绝权限申请请求。权限申请接口使用错误:混淆了Stage模型与FA模型的权限申请接口,使用了已废弃的FA模型接口(如requestPermissionsFromUser),而Stage模型需使用新的权限申请API,接口不兼容导致申请失败。权限申请时机与用户交互规范不符:未在用户触发相关功能(如点击“选择图片”按钮)时申请权限,而是在应用启动时直接申请,违反鸿蒙权限申请交互规范,系统拦截权限申请请求;同时未处理权限申请的回调结果,无法获取失败详情。特殊权限未做额外处理:存储权限属于危险权限,除常规申请外,未处理用户拒绝后再次申请的场景,也未引导用户手动开启权限;部分设备存在系统权限管控加固,需额外申请临时授权或适配系统权限策略。1.3 解决思路针对上述原因,围绕“完善权限配置、使用规范接口、遵循申请时机、处理特殊场景”四大核心思路展开,逐步解决权限申请及使用异常问题:检查并完善应用配置文件中的权限声明,确保权限名称、等级、格式符合鸿蒙Stage模型规范。替换为Stage模型专用的权限申请接口,遵循官方API使用规范,正确调用接口并处理回调结果。调整权限申请时机,在用户触发相关功能时申请权限,符合鸿蒙权限交互规范,避免系统拦截。处理危险权限的特殊场景,包括用户拒绝后再次申请的引导逻辑、手动开启权限的指引,适配不同设备的权限管控策略。 1.4 解决方案1. 完善module.json5权限配置在module.json5文件的“requestPermissions”数组中,声明存储权限(ohos.permission.READ_MEDIA_IMAGES),设置正确的权限等级(level为2,危险权限)和使用说明(reason),确保系统能识别权限需求。  2. 处理特殊场景与兼容性适配针对用户拒绝权限后再次申请的场景,添加二次引导逻辑,避免重复弹出授权弹窗被系统拦截;对于API 9与API 10的权限差异,添加版本判断,适配不同版本的权限名称(如部分旧版本存储权限为ohos.permission.READ_USER_STORAGE)。   1.5 总结本次存储权限申请失败问题,核心诱因是权限配置缺失、接口使用不规范、申请时机不符合交互规范及特殊场景未处理,属于鸿蒙Stage模型权限开发中的典型问题。解决此类权限问题的关键是:熟练掌握Stage模型权限配置与申请流程,区分新旧模型的接口差异,严格遵循鸿蒙权限交互规范,兼顾不同设备与API版本的兼容性。通过完善module.json5权限配置、使用Stage模型专用接口、在用户交互后申请权限、处理永久拒绝等特殊场景,可彻底解决权限申请及存储访问异常的问题。在日常开发中,建议提前梳理应用所需权限,按等级分类处理,危险权限需添加清晰的申请说明和引导逻辑;同时做好版本适配,避免因接口或权限名称变更导致的兼容性问题,提升应用稳定性。
  • [知识分享] 开发者技术支持-基于 Stack 与手势实现推荐岗位层叠卡片滑动切换动效
    1.1 问题说明在求职招聘应用的推荐岗位场景中,用户需要沉浸式浏览不同岗位信息,传统列表式展示交互性弱、视觉层次感不足,且缺乏便捷的滑动切换、投递 / 标记不感兴趣操作方式。本案例基于鸿蒙 Stack 组件、PanGesture 手势、animateTo 动画,实现推荐岗位的层叠卡片布局,支持左右滑动切换岗位、滑动删除 / 确认操作,提升岗位浏览的交互体验与操作效率,适配招聘类应用的核心使用场景。1.2 原因分析层叠布局层级控制难度大:多卡片叠加需精准管控 zIndex、尺寸、偏移、透明度等参数,参数搭配不合理易出现卡片遮挡、层级混乱、视觉层次感缺失的问题。滑动手势与动画协同复杂:PanGesture 的滑动距离、方向识别需与 animateTo 动画的执行时机、参数变更精准同步,若协同逻辑缺失,会出现滑动无响应、动画卡顿、卡片切换不流畅的情况。卡片状态动态更新易出错:滑动切换、删除操作后,剩余卡片需自动补位并更新布局参数,若参数更新逻辑不闭环,会出现卡片错位、布局错乱的问题。跨设备交互适配要求高:不同设备的屏幕尺寸、触摸灵敏度存在差异,若手势识别阈值、动画时长未做适配,会导致部分设备上滑动操作不灵敏、动画效果不协调。1.3 解决思路结构化参数封装:为每个岗位卡片定义包含 zIndex、size、opacity、offsetX、offsetY 的统一参数模型,通过数组管理所有卡片参数,确保布局状态可追溯、可统一更新。组件与手势动画组合使用:以 Stack 组件为基础实现卡片层叠布局,利用 PanGesture 识别用户滑动方向与距离,触发 animateTo 动画并动态更新卡片布局参数,实现手势与动画的无缝协同。闭环状态更新逻辑:滑动操作完成后,通过数组操作实现卡片的删除 / 切换,并自动重新计算剩余卡片的布局参数,完成补位更新,确保布局始终规整。标准化适配与约束:采用常量管理手势识别阈值、动画时长、布局基准参数,明确支持的鸿蒙 API、SDK 及开发工具版本,确保功能在目标设备上稳定运行。 1.4 解决方案核心布局实现(Stack 层叠卡片)基于 Stack 组件实现多岗位卡片的层叠布局,通过循环渲染卡片数组,为每个卡片绑定独立的布局参数,实现视觉上的层叠效果,核心代码如下:// 定义岗位卡片参数模型interface CardModel {  id: number; // 岗位唯一标识  img: string; // 岗位卡片图片资源  size: number; // 卡片尺寸比例  zIndex: number; // 卡片层级  opacity: number; // 卡片透明度  offsetX: number; // X轴偏移量  offsetY: number; // Y轴偏移量  positionInfo: string; // 岗位基础信息}// 定义卡片基础参数与数组@State arr: CardModel[] = []; // 岗位卡片数组@State cardWidth: number = 320; // 卡片基准宽度private baseSize: number = 1; // 基准尺寸比例private baseZIndex: number = 10; // 基准层级private baseOpacity: number = 1; // 基准透明度// Stack层叠布局核心代码Stack({ alignContent: Alignment.Start }) {  // 循环渲染所有岗位卡片  ForEach(this.arr, (item: CardModel, index: number) => {    Column() {      // 岗位卡片主内容(图片+岗位信息,可按需扩展)      Image(item.img)        .objectFit(ImageFit.Contain)        .width(this.cardWidth * item.size)        .height(569 * item.size)      Text(item.positionInfo)        .fontSize(16)        .margin({ top: 10 })        .width(this.cardWidth * item.size)        .textAlign(TextAlign.Center)    }    .zIndex(item.zIndex) // 控制卡片层级,数值越大越在上层    .opacity(item.opacity) // 控制卡片透明度,实现层叠渐变效果    .offset({ x: item.offsetX, y: item.offsetY }) // 控制卡片偏移量    .gesture(this.buildPanGesture(item, index)) // 绑定滑动手势  }, (item) => item.id); // 以唯一id作为循环标识,提升渲染性能}.width('100%').height('100%').alignItems(HorizontalAlign.Center)滑动手势实现(PanGesture)通过 PanGesture 识别用户的滑动方向(左 / 右)和滑动距离,触发对应的卡片操作(切换 / 删除),核心代码如下:// 构建滑动手势private buildPanGesture(card: CardModel, index: number): PanGesture {  return PanGesture()    .onActionUpdate((event: GestureEvent) => {      // 滑动过程中实时更新卡片X轴偏移量,实现跟随滑动效果      if (index === 0) { // 仅对最上层卡片做滑动跟随        this.arr[0].offsetX = event.offsetX;      }    })    .onActionEnd((event: GestureEvent) => {      // 定义滑动阈值,超过阈值则触发卡片操作      const slideThreshold = 100;      if (Math.abs(event.offsetX) > slideThreshold) {        // 根据滑动方向触发对应操作(左滑/右滑可自定义业务逻辑,如标记不感兴趣/投递)        this.handleCardSlide(event.offsetX > 0 ? 'right' : 'left');      } else {        // 未超过阈值,触发回弹动画,恢复卡片初始位置        this.resetCardPosition();      }    });}动画效果实现(animateTo)通过 animateTo 实现卡片滑动切换、删除、回弹的动画效果,在动画闭包中更新卡片布局参数,实现视觉流畅的动效,核心代码如下:// 处理卡片滑动操作(核心动画逻辑)private handleCardSlide(direction: 'left' | 'right') {  this.getUIContext().animateTo({    duration: 500, // 动画时长500ms    curve: Curve.EaseInOut, // 动画曲线,先慢后快再慢,提升流畅度    iterations: 1, // 动画执行次数    playMode: PlayMode.Normal, // 动画播放模式,正常播放    onFinish: () => {      // 动画结束后,删除最上层卡片,并重新计算剩余卡片布局参数      this.arr.shift();      this.resetCardLayout();    }  }, () => {    // 动画闭包中更新卡片参数,实现滑动离场效果    const offsetDistance = direction === 'left' ? -this.cardWidth * 1.5 : this.cardWidth * 1.5;    this.arr[0].offsetX = offsetDistance; // 卡片向指定方向滑出屏幕    this.arr[0].opacity = 0; // 滑出过程中透明度渐变为0  });}// 卡片回弹动画,恢复初始位置private resetCardPosition() {  this.getUIContext().animateTo({    duration: 300,    curve: Curve.EaseOut  }, () => {    this.arr[0].offsetX = 0; // 恢复X轴偏移量为0  });}// 重置剩余卡片布局参数,实现自动补位private resetCardLayout() {  this.arr.forEach((item, index) => {    // 按索引重新计算参数,实现层叠效果(越往后的卡片尺寸越小、层级越低、透明度越低)    item.size = this.baseSize - index * 0.05;    item.zIndex = this.baseZIndex - index;    item.opacity = this.baseOpacity - index * 0.1;    item.offsetX = 0;    item.offsetY = index * 10; // Y轴轻微偏移,增强层叠视觉效果  });}1.5 总结问题与痛点:招聘类应用传统岗位展示方式交互性弱、视觉层次感不足;缺乏便捷的滑动切换操作;多卡片布局易出现层级混乱;手势与动画协同易卡顿,影响用户浏览体验。技术要点:基于 Stack 组件实现岗位卡片层叠布局,通过统一参数模型管控卡片层级、尺寸、偏移等属性;利用 PanGesture 精准识别滑动方向与距离,实现手势交互;结合 animateTo 动画,在闭包中动态更新卡片参数,完成滑动切换、删除、回弹动效;通过数组操作与参数重计算,实现卡片滑出后的自动补位,保证布局规整。实现效果:实现推荐岗位的层叠式视觉展示,支持左右滑动切换岗位、滑动触发投递 / 标记不感兴趣等操作;手势识别灵敏,动画流畅无卡顿,卡片滑出后自动补位,布局始终规整;交互体验贴合用户使用习惯,大幅提升岗位浏览的沉浸感与操作效率。适用场景:求职招聘类应用的推荐岗位、精准匹配岗位场景;各类资讯类应用的卡片式内容推荐、滑动浏览场景;电商类应用的商品推荐、个性化推荐层叠卡片展示场景。
  • [技术干货] 开发者技术支持-下层组件触摸事件响应问题
     1.1 问题说明Row组件里面包含了三个子组件。左右两个子组件有自己的触摸事件,用于调整row组件的宽度。现在在此基础上为row组件增加触摸事件,用于row组件的整体移动。发现为row组件增加触摸事件后,子组件的触摸事件失效。 1.2 原因分析当上层组件与下层组件的触摸事件区域重合时,下层组件的触摸事件被上层组件拦截,无法做出响应。1.3 解决思路1. 梳理组件树对于触摸事件处理的机制,找到只让下层组件响应触摸事件,同时不影响上层组件触摸事件的方法。2. 为中间的子组件添加触摸事件响应,用于移动整个row组件。1.4 解决方案1. 组件树触摸事件处理机制根据网上的一些资料和测试总结如下:当手指触摸屏幕,系统会从UI根节点开始,根据触摸点坐标、组件的布局和属性,通过右子树优先的后序遍历算法收集所有可能响应的组件,形成一条有序的事件响应链。响应链构建后,系统进入手势识别阶段。一般情况下同一根手指在同一时间,最终只有一个手势能触发回调。其竞争遵循两个核心优先级规则:子组件优先于父组件:当父子组件绑定同类手势(如点击),触摸子组件区域时,只有子组件的手势会触发,父组件的被忽略。先达到条件者优先:同一组件绑定多个手势时(如同时监听点击和长按),哪个手势的条件先被满足(如先达到长按时间),哪个就触发。2. 手势竞争处理方法(1) 使用不同的手势绑定方法.gesture():默认方法,子组件优先。 .priorityGesture():用在父组件上,可使父组件手势优先于子组件。 .parallelGesture():用在父组件上,允许父子组件手势同时触发,不发生竞争。(2) 限制响应区域. responseRegion():可以定义一个与组件视觉布局完全脱钩的响应区域。只有触摸点落在该区域内,组件才会加入响应链。(3) 更改组件在触摸事件中的行为模式. hitTestBehavior():决定组件在触摸事件中的行为模式3. 最终解决方案在row组件使用默认行为模式及无任何修改的情况下,使用. hitTestBehavior(HitTestMode.Block)更改左右子组件的行为模式,自身响应,并阻塞兄弟节点和父节点。1.5 总结本次问题分享梳理了组件树关于触摸事件处理机制,并说明了手势竞争的三种处理方法,最终通过为子组件添加. hitTestBehavior(HitTestMode.Block)的方法,解决了当上层组件与下层组件的触摸事件区域重合时,下层组件的触摸事件被上层组件拦截,无法做出响应的问题。
  • 开发者技术支持-Canvas 中 clearRect 无法清除上一绘画内容
     1.1 问题说明在手写签名功能、实时轨迹跟随绘制(如绘画板)等鸿蒙原生应用场景中,基于 Canvas 实现触摸跟随绘制功能时,需实时清除历史轨迹以仅保留当前触摸路径。但实际开发中,调用 clearRect 方法后仍存在轨迹残留现象,表现为新旧轨迹叠加、界面杂乱,无法实现预期的“单条轨迹”显示效果,严重影响交互流畅性。1.2 背景知识Canvas 是鸿蒙应用中用于自定义图形绘制的核心组件,开发者通过 CanvasRenderingContext2D 等上下文对象执行绘制操作。绘制过程中,路径(path)的创建与渲染是分离的,beginPath 用于初始化路径,stroke/fill 用于将路径渲染为像素,而 clearRect 仅能清除已渲染的像素,无法直接删除未闭合的路径对象。1.3 原因分析核心原因是绘制流程中清除操作时机不当,未在每次新轨迹绘制前执行清除;同时,未及时通过 beginPath 重置路径对象,导致 clearRect 清除像素后,历史路径仍存在于上下文之中,后续 stroke 操作会重新渲染历史路径,造成残留。1.4 解决方案问题代码: Canvas(this.drawContext)  .width(this.canvasWidth)  .height(this.canvasHeight)  .backgroundColor('#F5DC62')  .onReady(() => {    this.drawContext.lineWidth = 10    this.drawContext.strokeStyle = '#0000ff'    // 仅初始化时执行一次beginPath,未随绘制更新    this.drawContext.beginPath()  })  .onTouch((event: TouchEvent) => {    // 手指按下时记录起点    if (event.type === 0) {      const { x, y } = event.touches[0]      this.drawContext.moveTo(x, y)    }    // 手指移动时绘制轨迹    if (event.type === 2) {      const { x, y } = event.touches[0]      this.drawContext.lineTo(x, y)      this.drawContext.stroke()    }  })Button('清除图形')  .onClick(() => {    // 仅清除像素,未处理路径    this.drawContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight);  }) 问题定位:清除操作仅在点击按钮时触发,无法实时配合绘制流程;且全程未重新调用 beginPath,历史路径持续累积,即使清除像素,后续绘制仍会重绘旧路径,导致残留。 解决方案:优化绘制时序+路径重置。重构绘制流程,在每次移动绘制前先清除画布,同时重置路径,确保上下文无历史路径残留。解决代码:// 记录上一次触摸坐标,用于轨迹衔接@State lastTouchX: number = 0;@State lastTouchY: number = 0; Canvas(this.drawContext)  .width(this.canvasWidth)  .height(this.canvasHeight)  .backgroundColor('#F5DC62')  .onReady(() => {    this.drawContext.lineWidth = 10    this.drawContext.strokeStyle = '#0000ff'  })  .onTouch((event: TouchEvent) => {    const context = this.drawContext;    const canvasSize = { width: this.canvasWidth, height: this.canvasHeight };    const currentTouch = event.touches[0];     // 1. 手指按下:初始化起点坐标    if (event.type === 0) {      this.lastTouchX = currentTouch.x;      this.lastTouchY = currentTouch.y;    }     // 2. 手指移动:执行清除-重置-绘制流程    if (event.type === 2) {      // 优化:坐标偏移过小时不重绘,减少性能消耗      if (Math.abs(currentTouch.x - this.lastTouchX) < 2 &&          Math.abs(currentTouch.y - this.lastTouchY) < 2) {        return;      }       // 步骤1:清除整个画布(确保无像素残留)      context.clearRect(0, 0, canvasSize.width, canvasSize.height);       // 步骤2:重置路径(关键!清除历史路径记录)      context.beginPath();       // 步骤3:绘制当前轨迹      context.moveTo(this.lastTouchX, this.lastTouchY);      context.lineTo(currentTouch.x, currentTouch.y);      context.stroke();       // 步骤4:更新起点坐标,衔接下一次绘制      this.lastTouchX = currentTouch.x;      this.lastTouchY = currentTouch.y;    }  })// 可选:保留手动清除按钮,需同步重置路径Button('手动清除轨迹')  .onClick(() => {    const context = this.drawContext;    context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);    context.beginPath(); // 同步重置路径,避免后续绘制残留  })1.5 总结·问题与痛点:clearRect 清除后仍有轨迹残留,新旧路径叠加导致界面混乱;传统绘制流程未区分“像素清除”与“路径清除”,无法满足单条轨迹显示需求。 ·技术要点:核心是厘清 Canvas 路径与像素的关系,通过“绘制前清除”优化时序,结合 beginPath 重置路径,实现像素与路径的双重清理;同时添加坐标偏移判断,平衡绘制流畅性与性能。·实现效果:优化后可实时清除历史轨迹,仅保留当前触摸路径,界面整洁;绘制响应流畅无卡顿,手动清除按钮也能彻底清理无残留,完全满足预期交互需求。·适用场景:手写签名功能、实时轨迹跟随绘制(如绘画板)、单条路径导航轨迹显示、需要精准控制绘制范围的交互场景。
  • [技术干货] 开发者技术支持-基于双列滚动选择器实现薪资范围联动约束效果
     1.1 问题说明在招聘类、求职类鸿蒙原生应用场景中,用户需要设置期望薪资范围(如"10K-15K")。传统单列选择器无法直观展示薪资区间,而双列选择器若不加约束,用户可能选出"最低薪资大于最高薪资"的无效组合(如"15K-10K")。本案例通过双列List滚动选择器与 onScrollIndex 回调实现薪资范围联动约束,确保最低薪资始终不大于最高薪资,从而提升数据有效性与用户操作体验。1.2 原因分析· 双列选择器状态同步复杂,易出现无效数据左右两列独立滚动,若不进行联动约束,用户可能选出逻辑矛盾的薪资组合,导致后续数据校验失败或业务逻辑异常。· 滚动事件与索引计算易出现偏差List组件的onScrollIndex返回的是可见区域首项索引,需要根据显示行数计算实际选中项,计算偏差会导致约束判断错误或滚动回弹位置不准确。· 初始化与滚动事件冲突组件初始化时需要设置默认滚动位置,若此时onScrollIndex 回调被触发,会干扰初始定位逻辑,导致选中项显示错乱。1.3 解决思路· 实时滚动监听与联动约束在onScrollIndex回调中实时获取当前滚动位置,计算选中项的薪资值,与另一列的当前值进行比较,若违反约束则强制回滚到合法位置。· 选中项索引精确计算根据 List 显示5行、中间行为选中项的布局特点,通过start +2计算实际选中项索引,并结合边界检查确保索引有效。· 初始化防抖机制使用标志位,在组件初始化完成前屏蔽onScrollIndex回调,避免初始滚动定位被干扰。1.4 解决方案薪资数据结构设计// 薪资选项数组,前后各添加2个空项(值为0),确保所有有效选项都能滚动到中间位置 // 当前选中索引(状态变量)@Local minSalaryIndex: number = 9  // 默认10K@Local maxSalaryIndex: number = 11 // 默认12K 初始化防抖与默认定位private isInitialized: boolean = false // 防止初始化时onScrollIndex干扰 aboutToAppear(): void {  // 解析当前薪资并设置索引...    // 延迟设置初始滚动位置,避免渲染冲突  setTimeout(() => {    this.minScroller.scrollToIndex(this.minSalaryIndex, false, ScrollAlign.CENTER)    this.maxScroller.scrollToIndex(this.maxSalaryIndex, false, ScrollAlign.CENTER)    this.isInitialized = true // 标记初始化完成  }, 50)} 最低薪资列联动约束List({ space: 0, scroller: this.minScroller }) {  ForEach(this.salaryOptions, (salary: number, index: number) => {    ListItem() {      Text(salary === 0 ? '' : `${salary}K`)    }  })}.onScrollIndex((start: number) => {  if (!this.isInitialized) return // 初始化完成前不响应    // 计算选中项索引(中间项 = start + 2)  const newIndex = Math.max(0, Math.min(start + 2, this.salaryOptions.length - 1))    if (this.salaryOptions[newIndex] !== 0) { // 跳过空项    const newMinValue = this.salaryOptions[newIndex]    const currentMaxValue = this.salaryOptions[this.maxSalaryIndex]        // 核心约束:最低薪资不能大于最高薪资    if (newMinValue <= currentMaxValue) {      this.minSalaryIndex = newIndex // 允许更新    } else {      // 违反约束,强制回滚到最高薪资位置      this.minScroller.scrollToIndex(this.maxSalaryIndex, true, ScrollAlign.CENTER)    }  }}).onScrollStop(() => {  // 滚动停止后自动对齐到选中项  this.minScroller.scrollToIndex(this.minSalaryIndex, true, ScrollAlign.CENTER)}) 最高薪资列联动约束.onScrollIndex((start: number) => {  if (!this.isInitialized) return    const newIndex = Math.max(0, Math.min(start + 2, this.salaryOptions.length - 1))    if (this.salaryOptions[newIndex] !== 0) {    const newMaxValue = this.salaryOptions[newIndex]    const currentMinValue = this.salaryOptions[this.minSalaryIndex]        // 核心约束:最高薪资不能小于最低薪资    if (newMaxValue >= currentMinValue) {      this.maxSalaryIndex = newIndex // 允许更新    } else {      // 违反约束,强制回滚到最低薪资位置      this.maxScroller.scrollToIndex(this.minSalaryIndex, true, ScrollAlign.CENTER)    }  }}) 1.5 总结· 问题与痛点:双列选择器无联动约束易产生无效数据;滚动索引计算复杂;初始化与滚动事件易冲突。· 技术要点:通过onScrollIndex实时监听滚动位置、start + 2计算选中项索引、条件判断实现联动约束、scrollToIndex强制回滚违规选择、isInitialized标志位实现初始化防抖。· 实现效果:用户滑动最低薪资列时,若选择值超过当前最高薪资,列表自动回弹到最高薪资位置;滑动最高薪资列时同理,确保始终满足"最低 ≤ 最高"的约束条件,操作流畅自然。· 适用场景:薪资范围选择、价格区间筛选、日期范围选择、数值区间设置等需要双列联动约束的交互场景。
  • [技术干货] 基于大文件复制沙箱阻塞主进程导致程序崩溃
     1.1 问题说明用户在使用网络上传或者下载文件时,会遇到将本地文件先复制到沙箱中的步骤,该步骤的必要性是鸿蒙系统的安全考虑,不能将本地文件直接作为源资源上传,所以需要先复制到具体应用自带的沙箱空间。复制文件会遇到大文件或者超大文件,这时候复制过程大概率会影响主进程其他任务,甚至应用崩溃问题。1.2 原因分析·  阻塞了主进程使用了阻塞的复制文件方法,该方法会导致主进程一直处于等待结束的过程中。1.3 解决思路· 使用异步复制文件方法:将原先使用的copyFileSync方法或者await copyFile,改成异步的copyFile(xxx, xxx).then(xxx).catch(xxx).finally(xxx)写法。· 调整fianlly结束块的位置:原来的finally代码块放在结尾,因为copyFileSync是同步方法,所以只有等到复制文件结束才关闭,现在使用异步复制文件,将finally结束块的代码写到copyFile异步方法后面,同时原来的catch语句块也需要加关闭文件流的代码,保证逻辑闭环。1.4 解决方案使用异步复制文件方法  fs.copyFile(srcFile.fd, dstFile.fd).then((res) => {          // 复制成功之后的回调        }).catch((err: BusinessError) => {          // 复制异常的回调        }).finally(() => {          if (srcFile) {// 关闭源文件文件流            fs.closeSync(srcFile);          }          if (dstFile) {// 关闭目标文件文件流            fs.closeSync(dstFile);          }        });调整fianlly结束块的位置 let srcFile: fs.File | null = null;      let dstFile: fs.File | null = null;      try {        // 文件打开和读取... ...// 异步复制文件        fs.copyFile(srcFile.fd, dstFile.fd).then((res) => {           // 复制成功之后的回调        }).catch((err: BusinessError) => {          // 复制异常的回调        }).finally(() => {          // 异步方法关闭文件流          if (srcFile) {            fs.closeSync(srcFile);          }          if (dstFile) {            fs.closeSync(dstFile);          }        });       } catch (e) {// 异常关闭文件流        if (srcFile) {          fs.closeSync(srcFile);        }        if (dstFile) {          fs.closeSync(dstFile);        }        return;      } 1.5 总结技术难点在于何处关闭文件流,关闭文件流是一个必要的步骤,如果缺少关闭文件流,会导致鸿蒙系统后面的文件上传复制操作都异常。复制文件使用异步方法可以避免阻塞主进程,同时也可以避免用户操作其他步骤导致复制文件失败的问题。
  • [技术交流] 基于鸿蒙 ArkUI 实现滚动文本智能悬停注释效果
    1. 问题说明在专业文档阅读、外语学习、技术手册浏览等鸿蒙应用场景中,用户常遇到生僻术语、专业概念或复杂公式,需频繁跳转查询释义,导致阅读节奏中断;传统静态注释要么遮挡文本,要么位置固定难以对应,触屏操作时精准点击小尺寸注释入口也存在不便。本案例基于 ArkUI 的滚动监听与动态定位技术,实现智能悬停注释效果,用户滑动文本时注释随目标内容同步滚动,点击或长按术语即可触发悬浮注释框,不破坏页面布局且操作直观,大幅提升专业内容阅读效率。2. 原因分析滚动同步逻辑复杂 文本滚动时注释需实时匹配目标术语位置,涉及滚动偏移量计算、组件坐标动态更新,易出现注释错位或跟随延迟。交互冲突风险高 滚动手势、点击 / 长按触发注释、注释框关闭等操作集中在文本区域,若事件优先级处理不当,会导致触发失效或误操作。多术语叠加显示混乱 同一屏幕出现多个可注释术语时,注释框易重叠遮挡,需智能判断显示位置避免视觉干扰。3. 解决思路滚动监听与坐标绑定通过 Scroll 组件的滚动事件监听,实时获取滚动偏移量,结合术语组件的初始坐标,动态计算注释框的悬浮位置。多手势优先级管理采用 “滚动事件优先、交互事件延迟响应” 机制,滚动时屏蔽注释触发,静止时激活点击 / 长按手势,避免操作冲突。智能布局避让策略根据术语在屏幕中的位置(顶部 / 中部 / 底部),自动选择注释框显示方向(下浮 / 上浮 / 侧浮),同时检测多注释框位置,避免重叠。4.解决方案滚动监听与坐标同步// 监听文本滚动事件,获取实时偏移量 Scroll() { Text(this.professionalText) .id("contentText") .onScroll((event: ScrollEvent) => { this.scrollOffset = event.scrollOffsetY; // 记录垂直滚动偏移量 this.updateAnnotationPosition(); // 同步更新注释框位置 }) } // 动态计算注释框坐标 updateAnnotationPosition() { if (this.activeTermId) { // 获取当前激活术语的组件坐标 const termRect = this.getComponentRect(this.activeTermId); // 结合滚动偏移量计算注释框悬浮位置 this.annotationX = termRect.x + termRect.width / 2 - 100; // 水平居中 this.annotationY = termRect.y - this.scrollOffset - 50; // 悬浮于术语上方 // 边界检测与避让调整 if (this.annotationY < 20) this.annotationY = termRect.y - this.scrollOffset + termRect.height + 10; } }手势交互与注释触发 // 术语文本组件,绑定长按+点击双触发方式 Text(term) .id(term-{index}`) .fontColor(Color.Blue) .onClick(() => { if (!this.isScrolling) { // 滚动状态下屏蔽点击 this.activeTermId = `term-{index}; this.annotationContent = this.getTermDefinition(term); this.showAnnotation = true; } }) .onLongPress(() => { if (!this.isScrolling) { this.activeTermId = term-${index}`; this.annotationContent = this.getTermFullExplanation(term); // 长按显示完整释义 this.showAnnotation = true; } }) // 滚动状态判断(避免滚动时误触发) .onScrollStart(() => { this.isScrolling = true; this.showAnnotation = false; // 滚动时隐藏注释框 }) .onScrollEnd(() => { this.isScrolling = false; })` 悬停注释框布局 // 悬浮注释框,通过绝对定位实现悬停效果 if (this.showAnnotation && this.annotationContent) { Text(this.annotationContent) .backgroundColor(Color.White) .borderRadius(8) .padding(12) .fontSize(14) .shadow({ radius: 4, offsetX: 2, offsetY: 2, color: Color.Gray }) .position({ x: this.annotationX, y: this.annotationY }) .width(200) .zIndex(999) // 确保层级高于文本内容 .onClick(() => { this.showAnnotation = false; // 点击注释框关闭 }) } 5.总结问题与痛点:专业内容阅读时查询注释中断节奏;静态注释遮挡或错位;小尺寸注释入口操作不便;多术语注释易重叠。技术要点:通过 Scroll 组件滚动监听实现位置同步、手势优先级管理避免操作冲突、动态坐标计算 + 边界检测实现智能避让、绝对定位 + 高层级渲染实现悬停效果。实现效果:用户滚动文本时注释框随目标术语同步移动,点击 / 长按术语即可触发悬浮注释,注释框自动避让屏幕边界与其他元素,不破坏页面布局,操作流畅且释义展示清晰,有效提升专业文本阅读连贯性与交互便捷性。适用场景:专业文档阅读 APP、外语学习应用、技术手册浏览界面、学术论文阅读工具、需要术语注释的知识类应用。 
  • [技术干货] 开发者技术支持-基于鸿蒙 ArkUI 实现滚动文本智能悬停注释效果
    1. 问题说明在专业文档阅读、外语学习、技术手册浏览等鸿蒙应用场景中,用户常遇到生僻术语、专业概念或复杂公式,需频繁跳转查询释义,导致阅读节奏中断;传统静态注释要么遮挡文本,要么位置固定难以对应,触屏操作时精准点击小尺寸注释入口也存在不便。本案例基于 ArkUI 的滚动监听与动态定位技术,实现智能悬停注释效果,用户滑动文本时注释随目标内容同步滚动,点击或长按术语即可触发悬浮注释框,不破坏页面布局且操作直观,大幅提升专业内容阅读效率。2. 原因分析滚动同步逻辑复杂 文本滚动时注释需实时匹配目标术语位置,涉及滚动偏移量计算、组件坐标动态更新,易出现注释错位或跟随延迟。交互冲突风险高 滚动手势、点击 / 长按触发注释、注释框关闭等操作集中在文本区域,若事件优先级处理不当,会导致触发失效或误操作。多术语叠加显示混乱 同一屏幕出现多个可注释术语时,注释框易重叠遮挡,需智能判断显示位置避免视觉干扰。3. 解决思路滚动监听与坐标绑定通过 Scroll 组件的滚动事件监听,实时获取滚动偏移量,结合术语组件的初始坐标,动态计算注释框的悬浮位置。多手势优先级管理采用 “滚动事件优先、交互事件延迟响应” 机制,滚动时屏蔽注释触发,静止时激活点击 / 长按手势,避免操作冲突。智能布局避让策略根据术语在屏幕中的位置(顶部 / 中部 / 底部),自动选择注释框显示方向(下浮 / 上浮 / 侧浮),同时检测多注释框位置,避免重叠。4.解决方案滚动监听与坐标同步`// 监听文本滚动事件,获取实时偏移量Scroll() {Text(this.professionalText).id(“contentText”).onScroll((event: ScrollEvent) => {this.scrollOffset = event.scrollOffsetY; // 记录垂直滚动偏移量this.updateAnnotationPosition(); // 同步更新注释框位置})}// 动态计算注释框坐标updateAnnotationPosition() {if (this.activeTermId) {// 获取当前激活术语的组件坐标const termRect = this.getComponentRect(this.activeTermId);// 结合滚动偏移量计算注释框悬浮位置this.annotationX = termRect.x + termRect.width / 2 - 100; // 水平居中this.annotationY = termRect.y - this.scrollOffset - 50; // 悬浮于术语上方// 边界检测与避让调整if (this.annotationY < 20) this.annotationY = termRect.y - this.scrollOffset + termRect.height + 10;}}`手势交互与注释触发// 术语文本组件,绑定长按+点击双触发方式 Text(term) .id(term-{index}`) .fontColor(Color.Blue) .onClick(() => { if (!this.isScrolling) { // 滚动状态下屏蔽点击 this.activeTermId = `term-{index}; this.annotationContent = this.getTermDefinition(term); this.showAnnotation = true; } }) .onLongPress(() => { if (!this.isScrolling) { this.activeTermId = term-${index}`;this.annotationContent = this.getTermFullExplanation(term); // 长按显示完整释义this.showAnnotation = true;}})// 滚动状态判断(避免滚动时误触发).onScrollStart(() => {this.isScrolling = true;this.showAnnotation = false; // 滚动时隐藏注释框}).onScrollEnd(() => {this.isScrolling = false;})`悬停注释框布局// 悬浮注释框,通过绝对定位实现悬停效果 if (this.showAnnotation && this.annotationContent) { Text(this.annotationContent) .backgroundColor(Color.White) .borderRadius(8) .padding(12) .fontSize(14) .shadow({ radius: 4, offsetX: 2, offsetY: 2, color: Color.Gray }) .position({ x: this.annotationX, y: this.annotationY }) .width(200) .zIndex(999) // 确保层级高于文本内容 .onClick(() => { this.showAnnotation = false; // 点击注释框关闭 }) }5.总结问题与痛点:专业内容阅读时查询注释中断节奏;静态注释遮挡或错位;小尺寸注释入口操作不便;多术语注释易重叠。技术要点:通过 Scroll 组件滚动监听实现位置同步、手势优先级管理避免操作冲突、动态坐标计算 + 边界检测实现智能避让、绝对定位 + 高层级渲染实现悬停效果。实现效果:用户滚动文本时注释框随目标术语同步移动,点击 / 长按术语即可触发悬浮注释,注释框自动避让屏幕边界与其他元素,不破坏页面布局,操作流畅且释义展示清晰,有效提升专业文本阅读连贯性与交互便捷性。适用场景:专业文档阅读 APP、外语学习应用、技术手册浏览界面、学术论文阅读工具、需要术语注释的知识类应用。
  • 开发者技术支持-鸿蒙轮播指示器自定义方案
    1. 问题说明内置指示器indicator无法满足设计需求,只能设置为圆角样式,无法设置成UI设计中的其他例如方块样式、进度条样式等个性化设计需求,且位置、交互效果等定制化程度有限。2. 原因分析内置指示器样式固定,仅支持基础圆点样式,无法自定义形状、颜色过渡和交互反馈位置调整受限,默认边距无法完全消除,难以实现贴边显示效果交互能力有限,不支持点击切换页面等高级交互功能动画效果单一,无法实现进度条式等动态展示效果3. 解决思路通过关闭默认指示器 + Stack布局叠加自定义视图的方式实现完全个性化的指示器效果,具体包括:使用Stack容器实现指示器与Swiper组件的视觉叠加通过onChange事件同步当前轮播索引状态利用ForEach动态生成指示器项,适配不同数据量结合animation属性实现平滑过渡动画封装独立组件提高复用性和性能4. 解决方案4.1 基础自定义方案(替代内置指示器)通过Stack布局叠加Row实现基础自定义指示器,支持选中状态变化动画:Stack({ alignContent: Alignment.Bottom }) {  // 主轮播内容  Swiper(this.swiperController) {    ForEach(this.imageList, (item) => {      Image(item)        .width('100%')        .height(240)    })  }  .indicator(false) // 关闭默认指示器  .onChange((index) => {    this.currentIndex = index // 同步当前索引  })  // 自定义指示器  Row({ space: 6 }) {    ForEach(this.imageList, (_, index) => {      // 动态改变选中项样式      Column()        .width(this.currentIndex === index ? 24 : 8)        .height(8)        .borderRadius(4)        .backgroundColor(this.currentIndex === index ? Color('#007DFF') : Color('#CCCCCC'))        .animation({ duration: 200, curve: Curve.EaseOut }) // 平滑过渡动画    })  }  .margin({ bottom: 16 }) // 底部间距}关键技术点:使用Stack布局实现指示器与Swiper的视觉叠加通过onChange事件同步当前轮播索引利用animation属性实现选中状态过渡效果推荐使用ForEach动态生成指示器项,避免硬编码4.2 进度条式指示器(高级自定义)实现随轮播进度动态增长的进度条指示器,结合属性动画与Swiper事件:@Componentstruct ProgressIndicator {  @Prop currentIndex: number  @Prop totalCount: number  @Prop duration: number // 与Swiper轮播间隔一致  build() {    Row({ space: 4 }) {      ForEach(Array.from({ length: this.totalCount }), (_, index) => {        Stack({ alignContent: Alignment.Start }) {          // 底层灰色轨道          Row()            .width('100%')            .height(2)            .backgroundColor('#666666')                    // 上层白色进度条          Row()            .width(this.currentIndex >= index ? '100%' : 0)            .height(2)            .backgroundColor('#FFFFFF')            .animation({               duration: this.currentIndex === index ? this.duration : 0,               curve: Curve.Linear             })        }        .layoutWeight(1)      })    }    .width('90%')    .margin({ bottom: 20 })  }}// 使用方式Stack() {  Swiper() {    // 轮播内容  }  .indicator(false)  .autoPlay(true)  .interval(3000)  .onChange((index) => {    this.currentIndex = index  })    ProgressIndicator({    currentIndex: this.currentIndex,    totalCount: this.imageList.length,    duration: 3000 // 与interval保持一致  })}实现原理:每个进度条由上下两层Row组件叠加而成(轨道+进度条)当前页面对应的进度条通过animation实现3秒线性增长已轮播页面进度条保持100%宽度未轮播页面进度条宽度为04.3 带交互功能的自定义指示器实现点击指示器切换页面功能,结合SwiperController与手势识别:struct InteractiveIndicator {  @Prop currentIndex: number  @Prop totalCount: number  controller: SwiperController // 接收Swiper控制器  build() {    Row({ space: 8 }) {      ForEach(Array.from({ length: this.totalCount }), (_, index) => {        GestureDetector() {          Column()            .width(this.currentIndex === index ? 16 : 8)            .height(8)            .borderRadius(4)            .backgroundColor(this.currentIndex === index ? Color.Red : Color.Gray)        }        .onClick(() => {          // 点击切换到对应页面          this.controller.showIndex(index)        })      })    }  }}// 使用方式private controller: SwiperController = new SwiperController()build() {  Stack() {    Swiper(this.controller) {      // 轮播内容    }    .indicator(false)    .onChange((index) => {      this.currentIndex = index    })        InteractiveIndicator({      currentIndex: this.currentIndex,      totalCount: 5,      controller: this.controller // 传递控制器    })  }}交互优化点:增大点击热区(建议最小8×8vp)添加点击反馈动画(如缩放、颜色变化)禁用快速连续点击(可通过防抖处理)确保指示器与Swiper滑动区域无重叠5. 常见问题解决方案5.1 内置指示器位置无法贴边显示问题现象:设置bottom: 0后仍有默认边距解决方案:通过负边距抵消内边距.indicator(Indicator.dot()  .bottom(-8) // 负边距调整  .left(0))原理:Swiper组件内部有默认内边距,需通过负外边距补偿5.2 自定义指示器与Swiper滑动冲突问题现象:点击指示器时触发Swiper滑动解决方案:提高指示器手势优先级GestureDetector() {  // 指示器内容}.priority(10) // 高于Swiper默认手势优先级.onClick(() => {  // 切换逻辑})原理:鸿蒙手势系统通过priority属性解决冲突,值越高优先级越高5.3 循环模式(loop=true)下索引异常问题现象:启用循环后指示器计数错误解决方案:对索引进行取模处理.onChange((index: number) => {  // 解决循环模式下索引溢出问题  this.currentIndex = index % this.totalCount})原理:loop模式下Swiper实际索引范围为[1, totalCount+1],需转换为[0, totalCount-1]6. 性能优化最佳实践6.1 减少重绘区域优化方案:将指示器独立为自定义组件,限制重绘范围@Componentstruct LightweightIndicator {  @Prop currentIndex: number  @Prop count: number  build() {    // 仅包含必要UI元素,避免复杂逻辑    Row({ space: 4 }) {      // 指示器项    }  }}性能收益:局部状态更新时仅重绘指示器区域,不影响Swiper主体6.2 避免过度动画优化建议:动画时长控制在200-300ms内优先使用系统内置曲线(如Curve.EaseOut)非关键状态变化可关闭动画.animation({  duration: 200,  curve: Curve.EaseOut,  iterations: 1 // 禁止无限循环})6.3 组件复用策略对频繁创建销毁的指示器项,使用**@Reusable**装饰器:@Reusable@Componentstruct ReusableIndicatorItem {  @Prop isSelected: boolean  build() {    Column()      .width(this.isSelected ? 20 : 8)      .height(8)      .backgroundColor(this.isSelected ? Color.Blue : Color.Gray)  }}适用场景:动态数据源的Swiper(如网络图片轮播)频繁更新的指示器(如实时数据展示)长列表轮播(如商品列表)
  • [案例共创] 【案例共创】基于仓颉、DeepSeek与RGF的AI桌宠开发实践
    前言不久前在仓颉的交流群中看到了关于仓颉案例共创的活动消息,于是想到可以用仓颉结合自身所长开发一个AI桌面宠物供大家娱乐学习。正好笔者封装的仓颉桌面渲染库 RGF_CJ 和控件库 RGF_UI 能满足这方面的需求,那么说干就干!成品效果开发前的准备在正式进行应用的开发之前,需要先准备好相关的依赖。领取华为云DeepSeek免费资源华为云为开发者提供了单模型200万Tokens的免费额度,包含DeepSeek-R1&V3满血版,登录华为云ModelArts Studio(MaaS)控制台即可领取,此处可以选择DeepSeek-R1满血版来搭建AI聊天桌宠。提示:点击此链接领取:https://www.huaweicloud.com/product/modelarts/studio.html进入网站签署服务声明领取免费额度并查看调用说明通过说明中的链接去创建API点击按钮创建API填写API相关信息获取API并保存提示:请保存好此处的API链接,后续代码开发中会用到。获取RGF依赖文件在后续应用的开发中会依赖到RGF_UI库,此库依赖到一些文件,可以从RGF_UI的库中获取。这些依赖文件后续会链接入应用中去。提示:点击此链接下载:https://gitcode.com/raozj/RGF_UI/tree/master/libs进入RGF_UI库的Libs目录,下载依赖文件保存依赖文件,留待后续使用安装仓颉STDX及相关依赖因为是基于仓颉开发通信相关的应用,因此需要依赖到STDX中的stdx.net.http、stdx.encoding.json、stdx.net.tls等包,而这些包自Cangjie v0.60.5版本开始从std标准库中移出,因此需要自行安装这些包以及这些包所依赖的OpenSSL。提示:点击此链接下载:https://gitcode.com/Cangjie/Cangjie-STDX注’ 由于笔者发文时Cangjie v0.60.5版本尚且处于内测阶段,因此若开发者无内测资格则无法下载到此版本的相关文件。关于 STDX 和 OpenSSL 的安装请参考上述链接中官方的文档,此处不做赘述。序列帧合图资源制作作为一个桌面宠物,自然需要拥有对应的动画,对此可以使用DragonBones、Spine或者Adobe Effect等软件制作。制作动画笔者在此使用的是DragonBones,关于DragonBones的安装和使用不在本文的探讨范畴之内,读者可自行选择喜欢的动画制作软件,设计并制作完成动画后输出序列帧以用于应用的开发。设计动画制作出序列帧制作合图资源如果每帧的图片都单独存储,对于应用的初始化加载和渲染都是一个较大的负担,因此建议将一类的序列帧动画按照相等宽高、间距合并为一张图片。由于手动合图比较麻烦,因此笔者写了一个Python脚本,快速的将目录中的序列帧图片按照指定的宽度分布数量合并为一张图片。注’ 由于此部分不属于文章核心部分,因此直接贴出代码以供参考,读者可通过其它工具或代码实现自己个性化的合图方案。import os import cv2 import numpy as np def load_images_from_folder(folder): """ 加载指定文件夹下的所有 .png 图像 """ image_files = [f for f in os.listdir(folder) if f.lower().endswith('.png')] image_files.sort() # 按文件名排序 images = [] for filename in image_files: file_path = os.path.join(folder, filename) # 读取图像(支持中文路径) with open(file_path, 'rb') as f: img_data = np.frombuffer(f.read(), dtype=np.uint8) img = cv2.imdecode(img_data, cv2.IMREAD_UNCHANGED) images.append(img) print(f"已加载: {filename}") return images def stitch_images(images, cols, bg_color=(0, 0, 0, 0)): """ 将图像按指定列数拼接成一张大图 :param images: 图像列表 :param cols: 每行显示的图像数量 :param bg_color: 背景颜色(支持 RGBA) :return: 拼接后的图像 """ if not images: raise ValueError("没有图像可供拼接") h, w = images[0].shape[:2] rows = (len(images) + cols - 1) // cols # 创建空白背景图 channels = images[0].shape[2] if len(images[0].shape) == 3 else 1 if channels == 1: bg_color = (bg_color[0], ) # 灰度图 elif channels == 3: bg_color = bg_color[:3] # RGB elif channels == 4: bg_color = bg_color[:4] # RGBA canvas = np.full((h * rows, w * cols, channels), bg_color, dtype=np.uint8) for idx, img in enumerate(images): row = idx // cols col = idx % cols x = col * w y = row * h canvas[y:y+h, x:x+w] = img return canvas def main(): folder = input("请输入包含PNG图像的文件夹路径:").strip() if not os.path.isdir(folder): print("输入的不是有效文件夹路径!") return try: cols = int(input("请输入每行显示的图像数量:")) except ValueError: print("请输入有效的数字!") return output_path = input("请输入输出图像的完整路径(如 output.png):").strip() images = load_images_from_folder(folder) if not images: print("未找到任何PNG图像!") return # 检查所有图像是否尺寸一致 first_h, first_w = images[0].shape[:2] for img in images[1:]: h, w = img.shape[:2] if h != first_h or w != first_w: print("警告:图像尺寸不一致,可能影响拼接效果。") # 拼接图像 print("开始拼接图像...") stitched_image = stitch_images(images, cols) # 保存结果 cv2.imwrite(output_path, stitched_image) print(f"拼接完成,已保存为: {output_path}") if __name__ == '__main__': main() 开发AI桌宠基本规划首先对项目目录和文件进行基本的规划,后续的开发就基于规划进行。目录规划Yez/ -------------------------------------------# 项目主目录 ├─libs/ ---------------------------------------# 依赖库 ├─res/ ----------------------------------------# 资源 │ └─SequenceFrame/ ---------------------------# 序列帧资源 │ └─skip_white/ ---------------------------# 椰椰的白色皮肤资源 ├─src/ ----------------------------------------# 源代码目录 └─tool/ ---------------------------------------# 工具目录文件规划Yez/ ├┈cjpm.lock ├┈cjpm.toml ├─libs/ │ ├┈libimm32.a -------------------------------# Windows 输入法库 │ ├┈resources.rc.o ---------------------------# 资源配置清单 内部声明应用依赖的最低系统版本 │ └┈libRgf.dll -------------------------------# RGF渲染库依赖的动态库 ├─res/ │ ├┈config.json ------------------------------# 椰椰的配置文件 │ └─SequenceFrame/ │ └─skip_white/ │ ├┈blink.json -------------------------# 眨眼合图描述文件 │ ├┈blink.png --------------------------# 眨眼动画合图 │ ├┈jump.json --------------------------# 跳跃合图描述文件 │ ├┈jump.png ---------------------------# 跳跃动画合图 │ ├┈speak.json -------------------------# 说话合图描述文件 │ ├┈speak.png --------------------------# 说话动画合图 │ ├┈wait.json --------------------------# 待机合图描述文件 │ └┈wait.png ---------------------------# 待机动画合图 ├─src/ │ ├┈chat.cj ----------------------------------# 大模型网络通信代码 │ ├┈config.cj --------------------------------# 配置管理代码 │ ├┈main.cj ----------------------------------# 程序主入口代码 │ ├┈res.cj -----------------------------------# 资源管理代码 │ ├┈win_anm.cj -------------------------------# 动画绘制窗口代码 │ ├┈win_input.cj -----------------------------# 输入框窗口代码 │ └┈win_text.cj ------------------------------# 鼠标文本渲染代码 └─tool/ └┈picture_puzzle.py ------------------------# python 合图脚本代码链接文件与库配置在开发之前,需要现为当前项目链接依赖文件和添加依赖库。链接文件在之前的小节中,下载了RGF_UI的依赖文件压缩包,在下载的压缩包内应该包含三个文件,即libRgf.dll、resources.rc.o和libimm32.a。这三个文件中,libRgf.dll是RGF_CJ库的依赖文件,必须随应用打包发布;resources.rc.o文件是Windows资源描述文件,读者也可以自行编写resources.rc文件并生成此文件(需设置最低支持Windows 8,即内核NT 6.2);libimm32.a文件是自绘编辑框控件依赖的文件,主要提供输入法相关的操作能力。在仓颉包管理配置toml文件中,需要加入链接上述文件的代码:[target] [target.x86_64-w64-mingw32] link-option = "-L ./libs -l:libRgf.dll -l user32 -l imm32" 库配置完成链接后,还需要为应用声明依赖RGF_CJ和RGF_UI包,并且为RGF_CJ包配置宏。提示:点击此链接查看依赖库RGF_CJ:https://gitcode.com/Cangjie-SIG/RGF_CJ提示:点击此链接查看依赖库RGF_UI:https://gitcode.com/raozj/RGF_UI[dependencies] [dependencies.rgf] git = "https://gitcode.com/Cangjie-SIG/RGF_CJ.git" [dependencies.rgfui] git = "https://gitcode.com/raozj/RGF_UI.git" [profile] [profile.customized-option] rgfCfg = "--cfg \"RGF_LANG=zh-cn\"" 上述代码中配置的“RGF_LANG=zh-cn”表示:当RGF_CJ出现异常时,捕获输出的异常提示内容为中文。配置加载模块笔者希望桌宠能够具备记忆配置的能力,因此需要为桌宠写一个配置的加载、保存模块,这个模块会记住桌宠所使用的皮肤、最后所在的桌面位置等信息,以确保桌宠重启后依然符合之前的情况。编写一个配置管理相关的恶汉单例类ConfigMgr,单例具备两个方法,一个用于从文件中加载配置,一个用于写出配置到文件中去。(其实也可以直接编写静态方法或直接编写函数而不使用类,看大家自己的选择吧。)代码如下:// Yez/src/config.cj // 配置管理类 class ConfigMgr { private static let _instance:ConfigMgr = ConfigMgr() public static prop i:ConfigMgr{ get(){ _instance } } private init() {} ... /** * 加载配置文件 * @return 是否成功 */ public func readConfig():Bool{ ... } /** 写出配置文件 */ public func writeConfig():Unit{ ... } ... } 编写一个ConfigInfo类,这个类用于存放实际的配置项,并且提供转换为Json的能力,代码如下:// Yez/src/config.cj class ConfigInfo <: Serializable<ConfigInfo> { // 椰椰右下角窗口坐标系的X坐标 var _x:Int32 = Int32.Min // 椰椰右下角窗口坐标系的Y坐标 var _y:Int32 = Int32.Min // 椰椰使用的皮肤 var _skip = "skip_white" // 椰椰画面缩放比例 var _zoom:Float32 = 0.125 // 椰椰的字体大小 var _fontSize:Float32 = 16.1 public func serialize(): DataModel { return DataModelStruct().add(field<Int32>("x", _x)) .add(field<Int32>("y", _y)) .add(field<String>("skip", _skip)) .add(field<Float32>("zoom", _zoom)) .add(field<Float32>("fontSize", _fontSize)) } public static func deserialize(dm: DataModel): ConfigInfo { var dms = match (dm) { case data: DataModelStruct => data case _ => throw Exception("this data is not DataModelStruct") } var result = ConfigInfo() result._x = Int32.deserialize(dms.get("x")) result._y = Int32.deserialize(dms.get("y")) result._skip = String.deserialize(dms.get("skip")) result._zoom = Float32.deserialize(dms.get("zoom")) result._fontSize = Float32.deserialize(dms.get("fontSize")) return result } } 将ConfigInfo以成员变量的形式加入到ConfigMgr中,并且由ConfigMgr对外提供访问属性,代码如下:// Yez/src/config.cj // 配置管理类 class ConfigMgr { // 配置信息 private var configImpl:ConfigInfo = ConfigInfo() ... public mut prop x:Int32{ get(){ return configImpl._x } set(val){ configImpl._x = val } } public mut prop y:Int32{ get(){ return configImpl._y } set(val){ configImpl._y = val } } public mut prop skip:String{ get(){ return configImpl._skip } set(val){ configImpl._skip = val } } public prop zoom:Float32{ get(){ return configImpl._zoom } } public prop fontSize:Float32{ get(){ return configImpl._fontSize } } } 接下来只要完善readConfig和writeConfig的读取和写出逻辑即可,代码如下:// Yez/src/config.cj // 配置文件路径 const CONFIG_FILE_PATH = "./res/config.json" ... public func readConfig():Bool{ // 判断目录是否存在 if(exists(CONFIG_FILE_PATH)){ let seqFramePath = canonicalize(CONFIG_FILE_PATH) let bytes = File.readFrom(seqFramePath) var jv = JsonValue.fromStr(StringReader(ByteBuffer(bytes)).readToEnd()) var dm = DataModel.fromJson(jv) configImpl = ConfigInfo.deserialize(dm) } // 目前始终返回 true // 当配置不存在时,则使用默认配置 return true } public func writeConfig():Unit{ if(!exists(CONFIG_FILE_PATH)){ File.create(CONFIG_FILE_PATH) } let seqFramePath = canonicalize(CONFIG_FILE_PATH) var Anm = configImpl.serialize() var dm = Anm.toJson() File.writeTo(seqFramePath,dm.toString().toArray()) } 最后,在入口函数中启用配置加载与写出即可。// Yez/src/main.cj main(): Int64 { // 加载配置文件数据 if(!ConfigMgr.i.readConfig()){ return 0 } ... // 保存配置到文件中去 ConfigMgr.i.writeConfig() return 0 } 资源加载模块除了配置之外,应用资源也是一个重要的部分,这部分的完整性会影响到应用是否正常渲染。首先采用和Config部分类似的结构准备恶汉单例类ResMgr和动画信息类AnmInfo,代码如下:// Yez/src/res.cj // 资源管理类 class ResMgr { private static let _instance:ResMgr = ResMgr() public static prop i:ResMgr{ get(){ _instance } } private init() {} // 动画 private static const ANM_BLINK ="blink" private static const ANM_JUMP ="jump" private static const ANM_SPEAK ="speak" private static const ANM_WAIT ="wait" private let _skipImg:HashMap<String, Array<(AnmInfo,MemBitmap)>> = HashMap<String, Array<(AnmInfo,MemBitmap)>>() public func loadRes():Bool{ ... } public func unloadRes(){ ... } public prop skip:HashMap<String, Array<(AnmInfo,MemBitmap)>>{ get(){ return _skipImg } } } class AnmInfo <: Serializable<AnmInfo> { // 单帧图片宽度 var width:Int32 = 0 // 单帧图片高度 var height:Int32 = 0 // 合图图片每行图片数量 var hCount:Int32 = 0 // 总帧数 var frames:Int32 = 0 ... } 然后就是实现在loadRes方法中加载合图及合图相关配置文件,代码逻辑还就是:从"./res/SequenceFrame"目录开始确保目录存在,然后遍历SequenceFrame目录下的所有皮肤目录,并且将皮肤目录内的合图资源及对应的配置文件载入到成员中去。如果目录下的资源文件不完整,则不会加载对应的内容到成员中。// Yez/src/res.cj /** * 代码较为复杂且不是核心代码,读者了解功能即可。 * 若需要查看相关代码,可下载项目查看。 */ public func loadRes():Bool{ ... } public func unloadRes(){ //释放所有图像资源(设备无关资源) for(skipPath in _skipImg){ skipPath[1][0][1].release() skipPath[1][1][1].release() skipPath[1][2][1].release() skipPath[1][3][1].release() } } 最后,在入口函数中启用资源加载与释放即可。// Yez/src/main.cj main(): Int64 { ... // 加载依赖资源 if(ResMgr.i.loadRes()){ ... } // 释放一切可能被加载的资源 ResMgr.i.unloadRes() ... return 0 } 大模型模块接下来便是与AI通信的核心部分了,此部分需要完成与大模型的互通。我们需要准备一个LLM类,这个类负责基于API、URL等信息与大模型建立链接,并且解析信息。// Yez/src/chat.cj class LLM { let client: Client let history = StringBuilder() public LLM( // 大模型URL链接 let url!: String, // 大模型 API Key let key!: String, // 大模型类型标识 let model!: String, // 上下文 let context!: Bool = false ) { var config = TlsClientConfig() config.verifyMode = TrustAll client = ClientBuilder() .tlsConfig(config) // AI 服务响应有时候比较慢,这里设置为无限等待 .readTimeout(Duration.Max) .build() } /** * 编码函数,将角色与内容编码为字符串 * return 字符串 */ func encode(role: Role, content: String) { ... } /** * 发送函数,将对话数据发送到服务器 * return HttpResponse */ func send(input: String, stream!: Bool = false) { ... } /** * 分析函数,解析服务器返回的数据并且提取其中有效的文本内容 * return 有效内容 */ func parse(text: String, stream!: Bool = false) { ... } /** * 流式对话 * @param input 输入的文本 * @param task 流式返回内容的回调函数 * @param taskEnd 返回内容结束的回调函数 */ public func chat(input: String, task!: (String) -> Unit = {o => print(o)},taskEnd!:()->Unit = {=>}) { ... } /** * 非流式对话 */ public func chat(input!: String) { ... } /** * 历史记忆预置 */ public func preset(context: Array<(Role, String)>) { ... } /** * 历史记忆清除 */ public func reset() { ... } } 根据华为云提供的调用参考,完善 send 方法代码如下:// Yez/src/chat.cj func send(input: String, stream!: Bool = false) { let message = encode(I, input) let content = '{"model":"${model}","messages":[${history}${message}],"stream":${stream}}' if (context) { history.append(message) } let request = HttpRequestBuilder() .url(url) .header('Authorization', 'Bearer ${key}') .header('Content-Type', 'application/json') .header('Accept', if (stream) { 'text/event-stream' } else { 'application/json' }) .body(content) .post() .build() client.send(request) } 根据华为云返回的数据格式,完善 parse 方法代码如下:// Yez/src/chat.cj func parse(text: String, stream!: Bool = false) { let json = JsonValue.fromStr(text).asObject() let choices = json.getFields()['choices'].asArray() if(choices.size() == 0){ return "" } // 流式和非流式情况下,这个字段名称不同 let key = if (stream) { 'delta' } else { 'message' } let message = choices[0].asObject().getFields()[key].asObject() if(message.containsKey("content")){ let content = message.getFields()['content'].asString().getValue() // 移除开头的两个 \n if(content == "\n\n"){ return "" } return content }else{ return "" } } 最后实现核心的流式对话部分,代码如下:// Yez/src/chat.cj public func chat(input: String, task!: (String) -> Unit = {o => print(o)},taskEnd!:()->Unit = {=>}) { // 根据传入的字符串构建上发服务器的 HttpResponse let response = send(input, stream: true) // 准备一个字符串缓冲区 let output = StringBuilder() // 准备一个字节缓冲区 let buffer = Array<Byte>(1024 * 8, {i=>0}) // 获取返回的内容 var length = response.body.read(buffer) while (length != 0) { // 将字节缓冲区内的内容按照UTF-8编码解析为字符串 let text = String.fromUtf8(buffer[..length]) const INDEX = 6 // 按行分割 for (line in text.split('\n', removeEmpty: true)) { // 确保获取的是完整的:data: {...} if (line.size > INDEX && line[INDEX] == b'{' && line[line.size - 1] == b'}') { let json = line[INDEX..line.size] // 分析出有效信息 let slice = parse(json, stream: true) if (context) { output.append(slice) } // 若有效信息不为空,则通过回调函数返回 if(slice != ""){ task(slice) } } } length = response.body.read(buffer) } if (context) { history.append(',${encode(AI, output.toString())},') } // 所有处理结束后,执行结束回调 taskEnd() } LLM类是一个较为通用的类,但是使用较为繁琐,因此对于外部调用而言,只需要一个简化的实例即可。因此,可以再编写一个独立的懒汉单例LLMImpl类,并且仅提供一个对外实现流式对话的chat方法,代码如下:// Yez/src/chat.cj class LLMImpl { private static var _i: Option<LLMImpl> = None public static prop i:LLMImpl { get(){ if(_i.isNone()){ _i = LLMImpl() } return _i.getOrThrow() } } private let robot:LLM // 初始化一个对话模型 private init(){ robot = LLM( // 华为云大模型调用URL url: 'https://maas-cn-southwest-2.modelarts-maas.com/v1/infers/8a062fd4-7367-4ab4-a936-5eeb8fb821c4/v1/chat/completions', // 如果示例自带的密钥失效,请自行注册,https://www.huaweicloud.com/product/modelarts/studio.html key: '/* !!! 这里填写读者自己的 API Key !!! */', // 要使用的 大模型 model: 'DeepSeek-R1', context: true ); // 设定基本信息 robot.preset([(System, #"我会自称“椰椰”并亲昵的在三句话之内回复所有问题。对于限定长度内无法阐述清晰的问题,我会表现出呆傻可爱的效果并说出“宕机”。如果对我说“拍照”或“截图”,我就会说“咔嚓~”。如果问我是谁开发的,我会说出“尧佥&椰子”。"#)]) } /** * 流式对话 * @param content 输入的文本 * @param task 流式返回内容的回调函数 * @param end 返回内容结束的回调函数 */ public func chat(content:String, task!: (String) -> Unit = {o => print(o)},end!:()->Unit = {=>}){ robot.chat(content,task:task,taskEnd:end) } } 注’ 完整的代码请参考提供的代码文件桌宠渲染接下来,需要实现可视化的窗口渲染,赋予“椰椰”一副“身体”。动画播放窗口为了实现AI桌宠的渲染,首先需要有一个窗口。对此,笔者选择基于RGF_CJ中的WinBase类,实现一个自己的窗口类WinMain。// Yez/src/win_anm.cj // 使用 RGF_CJ 的宏实现代码简化 @LifeCycle // 自动管理 设备相关资源 的释放 @HookProc // 自动挂载窗口类的消息事件方法 class WinMain <: WinBase{ ... // 窗口类初始化时,传入 ResMgr 载入的资源(设备无关资源) init(anm:Array<(AnmInfo, MemBitmap)>){ _anmRes = anm } /** * 加载设备无关资源 -> 设备相关资源 * @note 本部分将资源ResMgr类中的图片资源,载入到窗口类中 */ private func loadAnm(anm:Array<(AnmInfo, MemBitmap)>){ surface .createBitmapFromMemory(_bmps[0],anm[0][1]) .createBitmapFromMemory(_bmps[1],anm[1][1]) .createBitmapFromMemory(_bmps[2],anm[2][1]) .createBitmapFromMemory(_bmps[3],anm[3][1]) } /** * 创建设备资源事件,窗口渲染表面创建完成后,开始载入资源 */ public func createDeviceResources():Bool{ // 这里调用 loadAnm 载入图片资源 loadAnm(_anmRes) return true; } /** * 窗口创建完成事件,当窗口创建完成时,开始渲染动画 */ public func created(hwnd:RgfHwnd,x:Int32, y:Int32, nWidth:Int32, nHeight:Int32):Unit{ ... } /** * 绘制窗口事件,此处根据当前的动画状态渲染窗口 */ public override func onPaint(wRect:Rect):Unit{ surface.clear(0.0,0.0,0.0,0.0) match(_nowState){ case Wait => {=> // 等待状态的动画渲染比较特殊,因为需要实现眼睛跟随鼠标移动 if(_bmps[3].isValid() && _bmps[0].isValid()){ // 这一部分计算当前渲染帧的静态部分,即:身体, // 并且将这部分内容渲染到窗口 .... // 然后下面计算出鼠标对眼睛造成的偏移量 // 并且将眼睛渲染到身体上 if(_anmStep < _anmRes[0][0].frames){ .... }else{ .... } } }() case Jump => {=> // 这里渲染椰椰的跳跃动画 if(_bmps[1].isValid()){ .... } }() case Speak => {=> // 这里渲染椰椰正在说话的动画 if(_bmps[2].isValid()){ .... } }() } } } 在入口函数中,启用UIMain并且创建窗口,代码如下:// Yez/src/win_anm.cj main(): Int64 { ... if(ResMgr.i.loadRes()){ // 开始创建UI UIMain{=> // 确保皮肤配置有效 if(!ResMgr.i.skip.contains(ConfigMgr.i.skip)){ ConfigMgr.i.skip = ResMgr.i.skip.keys().toArray()[0] } // 创建窗口类 let anmWin:WinMain = WinMain(ResMgr.i.skip.get(ConfigMgr.i.skip).getOrThrow()) // 先随便在桌面 (0,0) 的位置创建一个 10像素×10像素 的窗口 // 窗口类内部会在显示前根据配置中的信息自动布局到正确位置和尺寸 anmWin.createWin(uiGetContext(), "Yez", 0, 0, 10, 10, 0, WinStyle.e2n(WinStyle.Popup), // 使用的是 TopMost 分层窗口(支持透明背景) dwExStyle:WinStyleEx.Default | WinStyleEx.TopMost, // 渲染使用的是 Direct2D 计数 layered:true,substrate:Substrate.Direct2D ) } } ... } 完成上述代码后,窗口已经可以正确的渲染动画了注’ 完整的代码请参考提供的代码文件编辑框窗口对于编辑框的开发是比较繁琐的,因此选择直接继承RGF_UI的UIInput类,然后将此控件修改为窗口,首先需要创建一个编辑框窗口类InputWin,代码如下:// Yez/src/win_input.cj public open class InputWin <: UIInput{ // 为编辑框设置独立的UI样式,而不是继承全局的样式 let css:UITheme = UITheme() init(){ super(theme:css) // 从配置中获取字体大小 css.fontSizeM = ConfigMgr.i.fontSize // 更新样式配置 css.update() } /** * 获取输入框窗口预期出现的位置 * @return 编辑框窗口坐标 * @note 本部分计算编辑框在当前椰椰所在桌面中心时的位置 */ private func getTargetLocation():Point{ // 方案二、桌面中心 let monitor = rsGetMonitorFromPoint(rsGetCursorPos()) let pos = Point( ((monitor.right - monitor.left) - INPUT_WIN_WIDTH) / 2, ((monitor.bottom - monitor.top) - INPUT_WIN_HEIGHT) / 2 ) return pos } /** * 创建窗口 */ public func createWin(hwndParent:RgfHwnd):RgfHwnd{ // 获取出现位置坐标 let pos = getTargetLocation() // 创建一个置顶层的无边框编辑框窗口 let retv = super.createWin( uiGetContext(),"",pos.x,pos.y,INPUT_WIN_WIDTH,INPUT_WIN_HEIGHT,hwndParent, WinStyle.PopupWindow | WinStyle.ClipChildren, dwExStyle:WinStyleEx.Default | WinStyleEx.TopMost, substrate:Substrate.Direct2D,layered:false,bgMod:BackGroundMod.CoverNotDraw,drawInterval:0 ) // 设置默认的内容为:“想对椰椰说什么:” content = INPUT_PROMPT // 创建好就显示窗口 show(true) return retv } } 完成编辑框窗口的创建后,还需要能响应相关的输入事件。对此,笔者希望:a) 当编辑框被按下或输入内容时,如果内容为默认内容,则清空提示内容;b) 当编辑框失去焦点时,窗口主动销毁自身;c) 当编辑框按下回车键时,将编辑框内容上发给大模型,并开始处理返回消息。实现自动清空内容很简单,代码如下:// Yez/src/win_input.cj /** 鼠标左键按下时,判断内容并清空 */ public override open func onLButtonDown(e:EvMouse):Unit{ if(content == INPUT_PROMPT){ content = "" } super.onLButtonDown(e) } /** 键盘按下某键时,判断内容并清空 */ public override open func onKeyDown(e:EvKey):Unit{ if(content == INPUT_PROMPT){ content = "" } super.onKeyDown(e) } 得益于RGF_CJ库的安全性,窗口销毁会自动释放资源,避免了繁琐的资源释放,和释放时机的管理。实现窗口失去焦点就是否窗口的代码如下:// Yez/src/win_input.cj /** 窗口失去焦点时,销毁窗口并释放窗口资源 */ public override open func onKillFocus(e:EvKillFocus):Unit{ destroyWin() } 希望实现按下回车键触发对话,则需要在对应的事件中调用LLMImpl类的流式对话方法,代码如下:// Yez/src/win_input.cj /** 字符输入事件 */ public override open func onChar(e:EvChar):Unit{ // 判断输入的是否为回车键 if(e.char == 0x0D/*VK_RETURN*/){ // 是回车键就销毁本窗口 destroyWin() // 并且调用 开始回答函数 start() // 然后调用 LLMImpl 类的流式对话方法 // 并且传入 流的回调函数 以及 回答结束的函数 LLMImpl.i.chat(content,task:task,end:end) }else{ super.onChar(e) } } // 回复流 public var task:(String) -> Unit = {str=>} // 开始回答 public var start:() -> Unit = {=>} // 开始回答 public var end:() -> Unit = {=>} 编辑框控件的效果如下图所示:注’ 完整的代码请参考提供的代码文件反馈窗口完成上述内容后,输入与处理已经基本完备,接下来需要实现文本的显示,这一部分涉及到内容绘制,同样还是基于WinBase类进行窗口开发。创建一个反馈文本的窗口类 WinText,并且准备基本的资源代码如下:// Yez/src/win_text.cj @Abbr // 文本色彩值转Color对象宏 @LifeCycle @HookProc open class WinText <: WinBase{ // 内容纯色画刷 private let _brhCnt:SolidColorBrush // 背景纯色画刷 private let _brhbg:SolidColorBrush // 文本渲染核心 private let _textCore:TextCore // 文本格式 private let _textformat:TextFormat // 文本布局 private let _textLayout:TextLayout // 文本内容 private var _text:String = "" /** 创建窗口 */ public func createWin( x:Int32, y:Int32, nWidth:Int32, nHeight:Int32, hWndParent:RgfHwnd, substrate!:Substrate = Substrate.Direct2D ):RgfHwnd{ ... } /** 文本对象管理方法,根据配置和窗口尺寸生成文本渲染依赖的对象 */ private func createFontObj(){ let rect:Rect = surface.getRect() surface.createTextCore(_textCore) .createTextFormat(_textformat,ConfigMgr.i.fontSize) .createTextLayout(_textLayout,_text,_textformat,Float32(rect.right),Float32(rect.bottom)) } /** 创建设备/渲染相关资源事件 */ public override open func createDeviceResources():Bool{ // 创建 纯黑内容画刷 和 白色半透明背景画刷 surface.createSolidColorBrush(_brhCnt,"#000",1.0) .createSolidColorBrush(_brhbg,"#FFFA",1.0) // 创建文本相关对象 createFontObj() return true; } } 完成渲染事件,确保内容正确渲染// Yez/src/win_text.cj // 绘制窗口 public override open func onPaint(wRect:Rect):Unit{ surface.clear(0.0,0.0,0.0,0.0) // 透明模式 // 渲染背景圆角矩形 surface.fillRoundedRectangle(surface.getRect(),4.0,4.0,_brhbg) // 渲染大模型返回的文本内容 surface.drawTexts(_textCore,_brhCnt,_textLayout,originX:0.0,originY:0.0) } 完成显示文本方法。当设置文本时,若窗口类已经存在,就更新内部文本;若窗口不存在,就根据当前内容创建窗口并显示。// Yez/src/win_text.cj public func showText(str:String,p:RgfHwnd):Unit{ // 获取光标位置 let pos = rsGetCursorPos() // 获取光标所在屏幕矩形范围 let monitor = rsGetMonitorFromPoint(pos) // 如果窗口未创建,就创建窗口 if(!isCreated()){ createWin(pos.x,pos.y,100,100,p) } if(_textLayout.isValid()){ if(str != _text){ // 更新文本内容 _text = str; // 创建文本布局信息 surface.createTextLayout(_textLayout,_text,_textformat,Float32(monitor.right - monitor.left) / 2.0,Float32(monitor.bottom - monitor.top) / 2.0) } // 获取文本自动布局后的测量数据 let m = _textLayout.getMetrics() let w = Int32(m.getOrThrow().width) let h = Int32(m.getOrThrow().height) 按照测量数据移动窗口 if(pos.x > (monitor.right - monitor.left) / 2){ move(pos.x - w,pos.y - h,w,h) }else{ move(pos.x,pos.y - h,w,h) } } // 窗口显示 show(true) } 文本控件的效果如下图所示:注’ 完整的代码请参考提供的代码文件联结各个窗口逻辑完成上述各个控件后,需要实现各个组件之间的联合。实现流式信息与动画的联动// Yez/src/win_anm.cj /** 文本变更事件 */ func onTextChange(str:String):Unit{ if(!_firstText){ _firstText = true // 当文本输出开始,切换“跳跃”动画状态到“说话”动画状态 play(AnmState.Speak) } _text += str _textBox.showText(_text,hWnd) } /** 大模型开始输出文本 */ func onTextStart():Unit{ killTimer(0) _text = "" // 给大模型上发请求后,播放“跳跃”动画 play(AnmState.Jump) _firstText = false } /** 大模型结束输出文本 */ func onTextEnd():Unit{ // 大模型输出结束后,延迟5s,以让用户完整阅读完内容 const cj_bug_val:Option<CFunc<(HWND, UInt32, UInt64, UInt32) -> Unit>> = None setTimer(0,5000,cj_bug_val) } /** 时钟事件 */ func onTimer(e:EvTimer):Unit{ killTimer(0) // 用户阅读完内容后,销毁文本框窗口 _textBox.destroyWin() // 播放“等待”动画 play(AnmState.Wait) } 双击“椰椰”时打开输入框窗口// Yez/src/win_anm.cj // 客户区双击鼠标左键 func onLButtonDblClk(e:EvMouse):Unit{ // 在“等待”状态下双击“椰椰” match(_nowState){ case AnmState.Wait => {=> // 销毁文本框(虽然已无可能还显示,不过保险起见) _textBox.destroyWin() if(!_inputBox.isCreated()){ // 创建文本输入框窗口 _inputBox.createWin(hWnd) }else{ // 如果窗口存在就移动输入框窗口 // 这部分适用于希望输入框跟随鼠标位置的情况 // 对于方案二的桌面居中而言,没有必要 _inputBox.move() } () }() case _ => () } } 结语至此,仓颉AI桌宠“椰椰”就基本开发完成了,其实还有很多规划的内容可以增加,比如当和椰椰说“咔嚓”或者“拍照”时激活截图功能。这些内容就留给读者继续探索啦!本文相关链接本文项目代码库:https://gitcode.com/raozj/YezRGF_CJ库:https://gitcode.com/Cangjie-SIG/RGF_CJRGF_UI库:https://gitcode.com/raozj/RGF_UIRGF案例库:https://gitcode.com/raozj/RGF_CJ_Example华为云ModelArts:https://www.huaweicloud.com/product/modelarts/studio.html
  • [其他问题] 有三个toddle.switch组件,怎么让他们互斥
    用的是ArkTs,要求这三个模式不能同时打开,要怎么做
  • [交流反馈] 多个警报弹窗能否合并
    目前乾坤终端安全检测到一个病毒就会弹窗一次,如果同时检测到多个病毒,用户需要多次点击关闭按钮才能将全部弹窗关闭,费时费力。能否考虑将这些弹窗合并在一起?参考ESET/Avast那样,可以一次性全部关闭。
  • [行业资讯] 2021年七个优秀的PostgreSQL GUI软件
    51CTO.com快译】什么是PostgreSQL GUI?它如何帮助企业管理PostgreSQL数据库?人们需要了解2021年一些优秀的PostgreSQL GUI软件。PostgreSQL是一种先进的开源对象关系数据库管理系统,可以支持SQL和JSON查询。根据Stack Overflow公司的一项调查,PostgreSQL是目前仅次于MySQL的第二大常用数据库。在对7万多名受访者的调查中,超过40%的人表示更喜欢采用PostgreSQL,而不是SQLite、MongoDB、Redis等其他数据库。作为Postgres用户,有两种方法来管理数据库:通过命命令行界面(CLI)编写查询(并非所有人都喜欢)。使用Postgres GUI,该界面由PostgreSQL管理工具之一构建。Postgres GUI比命令行界面(CLI)方便得多。此外,它还可以提高企业的工作效率。以下了解一下Postgres GUI和最常用的PostgreSQL GUI工具。什么是PostgreSQL GUI?PostgreSQL GUI是PostgreSQL数据库的管理工具。它允许企业或数据库用户查询、可视化、操作、分析其Postgres数据。还可以通过Postgres GUI访问数据库服务器。很多用户更喜欢Postgres GUI而不是CLI的主要原因是:漫长的学习曲线和复杂的使用流程。CLI界面不便于使用。控制台提供的信息不足。难以通过控制台浏览和监控数据库。反过来,使用Postgres GUI可为企业提供以下优势:快捷方式可用于更快、更简单的工作。丰富的数据可视化机会。可以访问远程数据库服务器。轻松地访问操作系统。优秀的PostgreSQL GUI软件对于某些用户来说,以Postgres为中心的pgAdmin并不是目前唯一可用的Postgres GUI工具,这可能出乎人们的意料。以下了解一下如今流行的一些PostgreSQL GUI管理工具。也许其中之一将会显著简化Postgres数据库管理。1.pgAdminpgAdmin是一个开源的跨平台PostgreSQL GUI工具。优点:与Linux、Windows、macOS兼容。允许同时使用多个服务器。CSV文件导出。查询计划功能。能够通过仪表板监控会话、数据库锁定。SQL编辑器中的快捷方式,使工作更方便。内部程序语言调试器旨在帮助代码调试。完整的文档和充满活力的社区。缺点:与一些付费的工具相比,其用户界面运行缓慢且不直观。笨重。不易上手。企业需要高级技能才能同时使用多个数据库。2.DBeaver这是一个支持多数据库的开源PostgreSQL管理工具。优点:跨平台。支持80多个数据库。作为可视化查询生成器,允许企业在没有SQL技能的情况下添加SQL查询。具有多个数据视图。CSV、HTML、XML、JSON、XLS、XLSX中的数据导入/导出。高级数据安全性。全文数据搜索和将搜索结果显示为表格/视图的能力。提供免费计划。缺点:与竞争对手相比运行速度较慢。更新过于频繁,令人烦恼。在闲置一段时间后,DBeaver会断开与企业的数据库的连接。企业需要重新启动应用程序。3.Navicat这是一个非常直观的Postgres数据库管理图形工具。Navicat并不是开源的工具。优点:非常容易和快速安装。获得Windows、Linux、macOS、iOS支持。方便快捷的可视化SQL构建器。具有代码自动完成功能。数据建模工具:操作企业的数据库对象、设计模式。作业调度程序:运行作业,在作业完成时获得通知。内置团队协作。数据源同步。以Excel、Access、CSV和其他格式导入/导出数据。通过SSH隧道和SSL确保数据保护。与亚马逊、谷歌和其他公司的云计算服务商合作。缺点:GUI工具性能不高。与竞争对手相比价格偏高。一个许可证只限于一个平台(用户需要PostgreSQL和MySQL两个单独的许可证)。许多高级功能需要时间来学习如何使用。使用不方便:添加行时需要更新应用程序。4.DataGrip由JetBrains构建的支持多个数据库的高级IDE。优点:跨平台(Windows、macOS、Linux支持)。简单的架构导航。带有查询控制台的可自定义用户界面(UI),可确保企业的工作进度安全。提示错误检测。内置版本控制系统。MySQL、SQLite、MariaDB、Cassandra和其他数据库支持。清晰的报告,能够将它们与图表和图形集成。强大的自动完成功能,建议相关代码完成。缺点:相当昂贵。消耗内存。复杂的错误调试过程。DataGrip和JetBrains具有长期的学习曲线。难以用作基于云计算的管理Web应用程序。不适合同时管理多个数据库。5.HeidiSQL这是一个开源Postgres(不仅仅是)GUI工具。现在仅支持Windows。优点:易于安装,与竞争对手相比非常轻巧。PostgreSQL、MySQL、Microsoft SQL Server、MariaDB支持。能够在一个窗口中连接和管理多个数据库服务器。从一个数据库或服务器到另一个数据库或服务器的直接SQL导出。通过简单易用的网格进行批量表格浏览和编辑。代码完成和语法突出显示功能。活跃的支持社区定期增强这个GUI工具。网格和数据导出为Excel、HTML、JSON、PHP文件。100%加密数据连接。缺点:不能跨平台使用(仅支持Windows)。问题频繁出现。没有程序语言调试器来简单地进行代码调试。6.TablePlus用于管理SQL和NoSQL数据库的原生GUI软件。TablePlus并不是开源的工具。优点:根据用户的反馈提供高性能和速度。高度可定制的用户界面:根本无需求助于Mojave。支持语法突出显示。快捷方式可以节省时间并提高效率。由于客户端-服务器连接的端到端加密,确保了更高级别的数据安全。缺点:当企业使用PostgreSQL以外的其他数据库时,经常出现用户体验不佳的问题。价格昂贵。而免费试用的功能进行严格限制。客户支持还有很多需要改进的地方。7.OmniDB这是一个简单的PostgreSQL开源GUI管理工具。优点:跨平台(获得Windows、Linux、macOS支持)。获得PostgreSQL、Oracle、MySQL、MariaDB支持。与某些替代品相比,响应速度快且更加轻巧。SQL自动完成功能。具有语法高亮显示功能。能够创建可定制的图表以显示相关的数据库指标。内置调试​​功能。缺点:如果同时使用多个数据库,则不是很适合。缺乏支持和学习文件。结语当企业选择GUI软件时,应该基于以下几个方面做出最终决定:团队规模。操作系统。数据库类型。计划使用的多个数据库。DBeaver、DataGrp和HeidiSQL更适合个人使用的数据库。由于具有GUI工具协作功能,Navicat是团队的最佳选择。除了支持Windows的HeidiSQL之外,几乎所有提到的工具都是跨平台的。pgAdmin以PostgreSQL为中心,作为PostgreSQL GUI工具的功能相当强大。但是采用可视化的内部工具构建器有UI Bakery。如果需要将多个不同的数据源集成在一起,那么这个低代码开发平台非常有用——无论是数据库、第三方工具还是API。而企业不必只局限在一个生态系统中。UI Bakery不是Postgres原生的。但是,它的数据可视化功能允许企业根据从PostgreSQL、MySQL、MS SQL Server、MongoDB、Redis、Salesforce和一系列其他数据库和应用程序中提取的数据,构建真正美观、易懂的图表、表格和图形。企业还可以使用预构建的用户界面(UI)组件和模板,避免从头开始构建,并节省更多的时间。如果企业不确定内部工具构建器适合自己的特定需求,可以继续进行尝试。整个GUI工具开发过程可能需要数小时的时间,有时甚至低至数分钟,具体取决于企业的开发经验。pgAdmin和其他经典的GUI软件似乎正在失去吸引力。Postgres和其他数据库管理的低代码方法使企业可以在更短的时间内获得更好的结果。原文标题:Top 8 PostgreSQL GUI Software in 2021,作者:Ilon Adams【51CTO译稿,合作站点转载请注明原文译者和出处为51CTO.com】
  • [技术干货] DLL load failed while importing win32gui: The specified module
     DLL load failed while importing win32gui: The specified modulewindows server 2008 运行Studio2.17失败,错误提示:[2022-01-21 10:31:56] robot finished with exception, exception details: DLL load failed while importing win32gui: The specified module could not be found.Traceback (most recent call last):File "./antrobot.py", line 121, in <module>File "./com/huawei/antrobot/framework/linux_adaptation/adapter.py", line 133, in runFile "./com/huawei/antrobot/framework/linux_adaptation/adapter.py", line 92, in releaseImportError: DLL load failed while importing win32api: The specified module could not be found.解决方法studio安装路径下\python\Lib\site-packages\pywin32_system32pythoncom39.dllpywintypes39.dll把这两个文件copy到如下路径即可C:\Windows\System32——————如果如下方法都无法解决,可以留言另外可以参考这个解决方法步骤 1 使用浏览器访问如下URL地址,下载并安装“Visual C++ Redistributable for Visual Studio 2015”。URL地址:https://www.microsoft.com/en-us/download/details.aspx?id=48145或者运行在安装目录里/Robot/cfg/cv/vc_redist.x64.exe步骤 2 根据PC机的操作系统版本,使用浏览器如下URL地址,下载并安装“KB3063858”操作系统补丁。 32-bit:https://www.microsoft.com/en-us/download/details.aspx?id=47409 64-bit:https://www.microsoft.com/en-us/download/details.aspx?id=47442步骤 3 重启PC,使得安装的软件或操作系统补丁生效。步骤 4 重新运行脚本,如上述步骤仍无法解决问题,请联系华为工程师进行定位分析和处理。还可以参考这个帖子https://bbs.huaweicloud.com/blogs/282058
  • [技术干货] javaGUI之对话框
                               第09课 GUI-对话框================================================简介:        JDialog类创建的对话框必须要依赖于某个窗口。       对话框分为无模式和有模式两种。            有模式的对话框:当这个对话框处于激活状态时,只让程序响应对话框内部的事件,                                           而且将堵塞其它线程的执行,用户不能再激活对话框所在程序中的其它窗口,直到该对话框消失不可见。           无模式对话框:当这个对话框处于激活状态时,能再激活其它窗口,也不堵塞其它线程的执行。 ------------------------------------------------------------------------------消息对话框----------------------------------------------javax.swing包中的JOptionPane类的静态方法:    public static void showMessageDialog(                       Component parentComponent,                       String message,                       String title,                       int messageType)------------------------------------------------------------输入对话框-------------------------------------------可以用javax.swing包中的JOptionPane类的静态方法:  public static  String showInputDialog(Component parentComponent,                                      Object message,                                      String title,                                      int messageType)-----------------------------------------------------------------------------------------确认对话框---------------------------------------------------------------------可以用javax.swing包中的JOptionPane类的静态方法:public static int showConfirmDialog(Component parentComponent,                                    Object message,                                    String title,                                    int optionType)------------------------------------------------------------颜色对话框-----------------------------------------------------------------------------可以用javax.swing包中的JColorChooser类的静态方法:                        public static Color showDialog(Component component,                                                String title,                                        Color initialColor)----------------------------------------------------------------------------------------------------自定义对话框--------------------------------------------------------------------------创建对话框与创建窗口类似,通过建立JDialog的子类来建立一个对话框类,然后这个类的一个实例,即这个子类创建的一个对象,就是一个对话框。对话框是一个容器,它的默认布局是BorderLayout,对话框可以添加组件,实现与用户的交互操作。 ---------------------------------------------------------------------------构造对话框的2个常用构造方法JDialog() 构造一个无标题的初始不可见的对话框,对话框依赖一个默认的不可见的窗口,该窗口由Java运行环境提供。JDialog(JFrame owner) 构造一个无标题的初始不可见的无模式的对话框,owner是对话框所依赖的窗口,如果owner取null,对话框依赖一个默认的不可见的窗口,该窗口由Java运行环境提供。