-
前言上一篇文章 开发者技术支持-数据变化后UI未重新渲染问题解决分享 通过更新@State变量内存地址,实现对象属性变化后触发 UI 重新渲染,但个人认为并非优雅的解决方案,鸿蒙提供了 @Observed/@ObjectLink 装饰器,配套使用,用于嵌套场景的观察。场景说明同样用上一篇文章的场景举例,具体移步《开发者技术支持-数据变化后UI未重新渲染问题解决分享》,一个用户列表数组,包含着用户对象(interface),UI 使用到用户对象中的某个属性来渲染,着个时候改变该属性的值,并不能触发 UI 重新渲染。问题说明下面这行代码是无法触发 UI 重新渲染的,因为 @State 修饰的是 followers ,只有更新 follower 的值,才能触发 UI 重新渲染// 无法触发 UI 重新渲染 this.followers[index].isFriend = !this.followers[index].isFriend解决思路利用@ObjectLink和@Observed的特性,实现深层属性观察。@ObjectLink和@Observed类装饰器用于在涉及嵌套对象或数组的场景中进行双向数据同步:使用new创建被@Observed装饰的类,可以被观察到属性的变化。子组件中@ObjectLink装饰器装饰的状态变量用于接收@Observed装饰的类的实例,和父组件中对应的状态变量建立双向数据绑定。这个实例可以是数组中的被@Observed装饰的项,或者是class object中的属性,这个属性同样也需要被@Observed装饰。解决方案首先将interface User改成class UserModel,并用@Observed装饰器修饰如下:/** * 用户 */ @Observed export class UserModel { name: string // 名称 avatar: string // 头像 isFriend: boolean = false // 是否好友(我也关注了他) constructor(user: User) { this.name = user.name this.avatar = user.avatar this.isFriend = user.isFriend } } 其次实现创建 Array 的子类 ObservedArray,并用@Observed装饰器修饰@Observed export class ObservedArray<T> extends Array<T> { constructor(...args: T[]) { super(...args) } } 列表项组件中的follower变量用@ObjectLink修饰import { UserModel } from '../common/UserModel' @Component export struct SecondFollowerView { @ObjectLink follower: UserModel onClickAttention?: () => void aboutToAppear(): void { console.log(`aboutToAppear: ${this.follower.name}`) } build() { Row({ space: 8 }) { Image(this.follower.avatar) .width(50) .height(50) .borderRadius(25) Column() { Text(this.follower.name) Text('') } .layoutWeight(1) .alignItems(HorizontalAlign.Start) Button(this.follower.isFriend ? '好友' : '关注') .backgroundColor(this.follower.isFriend ? Color.White : Color.Blue) .fontColor(this.follower.isFriend ? Color.Gray : Color.White) .borderWidth(this.follower.isFriend ? 1 : 0) .borderColor(Color.Gray) .onClick((event: ClickEvent) => { this.onClickAttention?.() }) } .width('100%') .height(80) } } 再来看看 SecondPage 中的数据源 datasource 类型改为 ObservedArray<UserModel>,那么改变数组中元素 UserModel对象的isFriend属性的值时,UI将触发重新渲染import { ObservedArray } from '../common/ObservedArray' import { User } from '../common/User' import { UserModel } from '../common/UserModel' import { SecondFollowerView } from '../components/SecondFollowerView' @Entry @Component export struct SecondPage { @State datasource: ObservedArray<UserModel> = new ObservedArray<UserModel>() aboutToAppear(): void { let jsonStr = '[{"name":"张三","avatar":"https://img0.baidu.com/it/u=3217838212,795208401&fm=253&fmt=auto&app=138&f=JPEG?w=514&h=500","isFriend":true},{"name":"李四","avatar":"https://img0.baidu.com/it/u=4186430229,801747038&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500","isFriend":false},{"name":"王五","avatar":"https://img1.baidu.com/it/u=728383910,3448060628&fm=253&fmt=auto&app=120&f=JPEG?w=800&h=800","isFriend":false},{"name":"赵六","avatar":"https://img0.baidu.com/it/u=1096585807,3493972554&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500","isFriend":false}]' let followers = JSON.parse(jsonStr) as User[] let userModels: UserModel[] = followers.map((follower: User) => { return new UserModel(follower) }) this.datasource = new ObservedArray(...userModels) } build() { NavDestination() { Column() { List() { ForEach(this.datasource, (follower: UserModel, index) => { ListItem() { SecondFollowerView({ follower: follower, onClickAttention: () => { // @Observed 结合 @ObjectLink 实现深层观察,可以出发 UI 重新渲染,且不重新创建组件 this.datasource[index].isFriend = !this.datasource[index].isFriend } }) .padding({ left: 20, right: 20 }) } }) } .height('100%') .width('100%') } .height('100%') .width('100%') } .title('SecondPage') } }
-
一、问题说明在涉及敏感数据传输(如用户身份、支付信息)的前端业务中,前后端需约定加密协议(如 AES、SM4)保障传输安全。开发中面临的核心问题包括:1.代码冗余:每个请求需手动调用加密函数,导致业务逻辑中充斥重复的加密/解密代码,维护成本高。2.密钥管理风险:密钥硬编码在业务代码中,易泄露且难以轮换。3.开发效率低:调用方需关注加密细节(如算法选择、数据序列化),违反关注点分离原则。二、原因分析1.缺乏请求层抽象:Axios 原生不支持自动加解密,需在每个请求中手动处理,导致加密逻辑分散。2.加密与业务逻辑耦合:加密操作侵入业务代码,如以下冗余模式:// 业务代码中显式加密 const encryptedParams = encrypt(rawData, key); axios.post('/api', encryptedParams);3.密钥暴露风险:密钥通过明文存储在前端代码中,攻击者可通过源码分析获取密钥。 三、解决思路1.拦截器封装:在 Axios 的请求/响应拦截器中注入加解密逻辑,实现调用方无感知。2.统一密钥管理:通过环境变量或安全服务动态获取密钥,避免硬编码。3.支持多加密算法:抽象加解密接口,我会以 AES(通用)或 SM4(国密)举例。四、解决方案1. 核心架构 2. 代码实现(以 AES-CBC 模式为例)步骤 1:封装加解密工具// utils/crypto.ts import CryptoJS from "crypto-js"; const AES_KEY = import.meta.env.VITE_AES_KEY; // 从环境变量读取密钥 const IV = "ABCDEF1234567890"; // 初始化向量 // 加密函数 export const encrypt = (data: any): string => { const encrypted = CryptoJS.AES.encrypt( JSON.stringify(data), CryptoJS.enc.Utf8.parse(AES_KEY), { iv: CryptoJS.enc.Utf8.parse(IV), mode: CryptoJS.mode.CBC } ); return encrypted.toString(); }; // 解密函数(泛型支持类型推断) export const decrypt = <T>(ciphertext: string): T => { const bytes = CryptoJS.AES.decrypt(ciphertext, CryptoJS.enc.Utf8.parse(AES_KEY), { iv: CryptoJS.enc.Utf8.parse(IV), mode: CryptoJS.mode.CBC }); return JSON.parse(bytes.toString(CryptoJS.enc.Utf8)) as T; };步骤 2:Axios 拦截器注入// utils/request.ts import axios from "axios"; import { encrypt, decrypt } from "./crypto"; const service = axios.create({ timeout: 10000 }); // 请求拦截器:自动加密参数 service.interceptors.request.use((config) => { if (config.data) { config.data = { cipher: encrypt(config.data) }; // 封装为密文字段 } return config; }); // 响应拦截器:自动解密数据 service.interceptors.response.use((response) => { if (response.data?.cipher) { response.data = decrypt(response.data.cipher); // 解密密文字段 } return response.data; }); export default service;步骤 3:业务层调用(无加密痕迹)import request from "@/utils/request"; // 调用示例(与普通请求无差别) const fetchUserData = async () => { const res = await request.post("/api/user", { userId: 123 }); console.log(res.name); // 直接获取明文数据 };3. 进阶优化1.国密 SM4 支持:替换 crypto.ts中的加密逻辑为 SM4 实现,业务层无需改动:import { sm4 } from "sm-crypto"; export const encrypt = (data: any) => { return sm4.encrypt(JSON.stringify(data), SM4_KEY); };2.防重复提交:在拦截器中添加请求指纹校验,避免加密导致重复请求:const pendingMap = new Map(); service.interceptors.request.use(config => { const key = `${config.url}-${JSON.stringify(config.data)}`; if (pendingMap.has(key)) { return Promise.reject("重复请求"); } pendingMap.set(key, true); return config; });3.密钥动态获取:首次启动时从后端获取临时密钥,定期轮换提升安全性。(这个主要看业务需求,在安全级别不是很高的情况下,使用本地的即可,因为使用服务端密钥的话,需要另一套机制来保证秘钥的传输与存储,此处暂且不提)下一篇,我将介绍《保障客户端加密密钥安全:告别明文存储的隐患与ArkTS实战》
-
一、问题说明 在应用开发中,页面通常具备完整的生命周期,但在使用 Tab 组件管理页面时,会出现特定生命周期函数未按预期触发的问题:当从 Tab 组件所管理的某一页面(下称 “Tab 页面”)跳转至其子页面后,再从子页面返回原 Tab 页面时,原 Tab 页面的 onPageShow 生命周期回调函数未被触发,导致依赖该函数的逻辑(如数据刷新、状态重置等)无法正常执行。二、问题原因 当将 Tab 管理的页面改造为组件形式时,由于组件本身不具备 onPageShow 这类页面级生命周期函数,原依赖该生命周期实现的逻辑(如页面显示时的数据刷新、状态同步等)会失去触发载体,导致相关功能失效。三、解决思路 当无法触发 onPageShow 生命周期时,我们可以通过设计一个全局或局部的监听对象来实现页面刷新机制:可以创建一个可被监听的状态对象,在页面需要感知显示状态的位置监听该对象的变化。当从子页面返回 Tab 组件页面时,主动修改这个监听对象的状态(例如更新一个时间戳或状态标记)。此时,Tab 组件页面由于监听了该对象,会感知到变化并执行相应的刷新逻辑(如重新请求数据、更新视图等)。这种方式通过状态驱动的思路,绕过了对页面级生命周期的依赖,利用状态变化触发更新,从而实现类似 onPageShow 的效果。四、解决方案 在全局或相关页面共享的作用域中,定义一个可被监听的状态参数(如refreshFlag)。当需要从子页面返回 Tab 页面并触发刷新时,先在子页面中主动修改该参数的值(例如递增序号、切换布尔值状态等),随后再执行跳转回 Tab 页面的操作。 由于 Tab 页面预先通过监听器(如 Vue 的 watch、React 的 useEffect 依赖监听等)监测着refreshFlag的变化,当参数值被修改后,Tab 页面会立刻感知到这一变化,进而自动执行数据重新获取、视图更新等刷新逻辑。 这种方式通过 "状态修改先行,跳转操作随后" 的顺序设计,利用参数变化作为信号,成功替代了 onPageShow 的触发时机,确保 Tab 页面在返回时能可靠执行刷新操作。 @Watch('changeReload') @StorageLink('changeData') data: 类型= 值 changeReload(){需要执行的方法}
-
一,问题说明鸿蒙接入shareSDK后先授权,然后调用分享功能不能拉起微信或qq二,原因分析添加依赖在Terminal窗口中,执行如下命令进行安装ohpm install @zztsdk/zztcoreohpm install @zztsdk/sharesdkohpm install @yyz116/jsbn 权限配置ShareSDK需要 INTERNET权限才可正常使用,请在工程中entry模块的 module.json5文件中,新增 requestPermissions,如下所示:"module": { "name": "xxx", "type": "entry", "description": "xxx", "mainElement": "xxx", "deviceTypes": [], "pages": "xxx", "abilities": [], // 配置如下 "requestPermissions":[ { "name": "ohos.permission.INTERNET" } ]} 使用ShareSDK前,需调用以下代码初始化ShareSDK,可以放在EntryAbility文件中调用ZztSDK.init(context, "您的AppKey", "您的AppSecret") 通过询问客服得知,使用shareSDK的分享功能是不需要授权的,这俩个功能不能写到一起。如下是错误示范,如下图这样把授权功能和分享功能写在一起,这样会拉起第三方软件实现授权,而后续的分享功能不会重新拉起第三方软件,这样分享功能连反馈都不会有。let plat = await mobShare.ShareSDK.getInstance().getPlatformAsync(mobShare.Platform.HUAWEI)let records: Array<mobShare.SharedParam> = new Array()let receive: mobShare.PlatformActionListener = { onComplete: (platform: mobShare.IPlatform, action: number, res: Map<string, Object>) => { //成功回调 }, onError: (platform: mobShare.IPlatform, action: number, error: Error) => { //异常回调 }, onCancel: (platform: mobShare.IPlatform, action: number) => { //取消回调 }}plat.setPlatformActionListener(receive)plat.authorize(params)records.push({utd: mobShare.ShareType.TEXT,content: "测试分享文本",})let plat =await mobShare.ShareSDK.getInstance().getPlatformAsync(mobShare.Platform.SYSTEM)plat.setPlatformActionListener(receive)plat.share(records, getContext(), { previewMode: mobShare.SharePreviewMode.DEFAULT, selectionMode: mobShare.SelectionMode.SINGLE}) 三、解决思路隐私授权登录和分享功能是俩个功能,授权是为了第三方登录,而分享信息到第三方软件是不需要授权的,如果把授权和分享写在一起,这样会拉起第三方软件实现授权,而后续的分享功能不会重新拉起第三方软件,这样分享功能连反馈都不会有。所以要使用哪个功能直接单独调用就行。四、解决方法正常来说shareSDK的分享功能和授权功能是互不影响的,如分享单独调用以下接口就行let records: Array<mobShare.SharedParam> = new Array()records.push({utd: mobShare.ShareType.TEXT,content: "测试分享文本",})let receive: mobShare.PlatformActionListener = { onComplete: (platform: mobShare.IPlatform, action: number, res: Map<string, Object>) => { //成功回调 }, onError: (platform: mobShare.IPlatform, action: number, error: Error) => { //异常回调 }, onCancel: (platform: mobShare.IPlatform, action: number) => { //取消回调 }}let plat =await mobShare.ShareSDK.getInstance().getPlatformAsync(mobShare.Platform.SYSTEM)plat.setPlatformActionListener(receive)plat.share(records, getContext(), { previewMode: mobShare.SharePreviewMode.DEFAULT, selectionMode: mobShare.SelectionMode.SINGLE}) 这个时候如果没有拉起分享功能,onError回调就能监听到具体错误了。同理,调用授权功能也是如此。
-
代码文章:https://developer.huawei.com/consumer/cn/blog/topic/03189702153576069常见的多行跑马灯效果可以随时停留
-
数据库相关文章:https://developer.huawei.com/consumer/cn/blog/topic/03191259102976177对于数据库中的数据类型处理时布尔值,如果直接给表格定义如:db.execDML(‘ALTER TABLE tb_user ADD COLUMN is_student boolean’)@TableField({name:“is_student”,type:FieldType.BOOLEAN})这样定义是没有值的我们需要定义成为db.execDML(‘ALTER TABLE tb_user ADD COLUMN is_student integer’);@TableField({name:“is_student”,type:FieldType.NUMBER})使用和取值都不会影响,因为ORM 框架会自动转换
-
1.问题说明使用LazyForEach加载页面后,更新某一个item的标题、封面等数据,不能正常触发刷新;2.问题分析一开始以为是DataSource出现了问题,参照项目原有的重构了一遍,发现还是有问题。仔细阅读官方文档后,在使用限制中发现:LazyForEach依赖生成的键值判断是否刷新子组件,键值不变则不触发刷新。开始怀疑我的键值是不是没有更新导致的刷新异常,继续阅读,发现系统默认的键值生成规则是 viewId + ‘-’ + index.toString(),不包含我修改过的标题,封面内容。3.解决思路既然系统生成的键值不包含我们修改的内容,那么把需要触发的参数加进去就可以了4.解决方案自定义keyGenerator,将会触发item刷新的参数都加进键值里,比如修改item标题会刷新,就把animal.name加进去,修改封面要刷新就把animal.cover也加上LazyForEach(this.animals, (animal: Animal) => { ListItem() { Row() { Text(animal.name) Image(animal.cover) }.margin({ left: 10, right: 10 }) } }, (item: string) => item + animal.name + animal.cover)
-
一,问题说明html样式字符串不好进行解析,字体样式只有有这种样式才会添加到字符串里面,同时html里面的图片不能直接进行加载到RichEditor,类似如下字符串,开始无法进行图片下载,后续http下载图片之后加载有部分图片无法加载,但是会占位。"<span style=\"color: #000000E5;\">联系联系了都</span><img src=\"https://counselorcat.wisedu.com/wec-im-message/proxy/file/P2VK8HyzB6O\" ><span style=\"color: #000000E5;\">辛苦辛苦辛苦快下课女吧哈哈哈么么么么么么么宝贝宝贝</span><span style=\"font-style: normal;font-weight: bolder;color: #000000E5;\">宝贝</span><span style=\"color: #000000E5;\">宝贝</span>" 二,原因分析,解决思路,解决方法无法加载图片分析错误码得知有些图片内存过大,无法通过http进行下载,需要添加参数maxLimit,设置请求最大超过5mb,同时下载的时候要携带cookielet response = await httpRequest.request(url, { method: http.RequestMethod.GET, maxLimit: 1024 * 1024 * 100, header: { 'Cookie':cookie }});同时转化成ArrayBuffer保存到文件中并且转化成文件路径,路径自定义,最后通过控制器回显// 将图片数据写入沙箱路径let file = fileIo.openSync(filePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE);fileIo.writeSync(file.fd,response.result as ArrayBuffer);let uri = fileUri.getUriFromPath(filePath)this.controller.addImageSpan(uri, {offset: this.cursorPosition});将字符串通过正则进行分割成字符串数组/** * 按照不同标签将字符串分割成数组 * @param input 包含HTML标签和图片的字符串 * @returns 分割后的字符串数组 */splitStringByTags(input: string): string[] { const result: string[] = []; // 匹配<span>标签、<img>标签及其内容的正则表达式 const tagRegex = /(<span[^>]*>[^<]*<\/span>)|(<img[^>]*>)/g; // 使用正则表达式匹配所有标签 let match: RegExpExecArray | null; let lastIndex = 0; while ((match = tagRegex.exec(input)) !== null) { // 添加标签前的文本内容(如果有) if (match.index > lastIndex) { const textContent = input.substring(lastIndex, match.index); if (textContent.trim()) { result.push(textContent); } } // 添加匹配到的完整标签 result.push(match[0]); lastIndex = match.index + match[0].length; } // 添加最后剩余的文本内容(如果有) if (lastIndex < input.length) { const remainingText = input.substring(lastIndex); if (remainingText.trim()) { result.push(remainingText); } } return result;}再将分割后的字符串进行处理,拿到自己要的文字和文字格式,和图片地址进行下载,按照顺序进行回显/** * 根据标签数组将内容添加到RichEditor中 * @param tags 分割后的标签数组 */async addSpansFromTags(tags: string[]): Promise<void> { // 延迟执行确保RichEditor初始化完成 for (let i = 0; i < tags.length; i++) { const tag = tags[i]; // 判断是否为图片标签 if (tag.startsWith('<img')) { // 提取图片src属性 const srcMatch = tag.match(/src=["']([^"']*)["']/); if (srcMatch && srcMatch[1]) { const imgSrc = srcMatch[1]; console.log('图片地址',imgSrc) // 添加图片到RichEditor let a= await this.downloadImage(imgSrc, `${Date.now()}.jpg`) console.log('下载图片成功',a) let uri = fileUri.getUriFromPath(a) console.log('图片地址23',uri) this.controller.addImageSpan(uri, {offset: this.cursorPosition}); } } // 判断是否为span标签 else if (tag.startsWith('<span')) { // 提取文本内容 const textMatch = tag.match(/<span[^>]*>([^<]*)<\/span>/); if (textMatch && textMatch[1]) { const textContent = textMatch[1]; // 检查是否有特定样式 let Style: RichEditorTextStyle = {}; let textStyle: RichEditorTextSpanOptions = {}; // 检查粗体 if (tag.includes('font-weight: bolder')) { Style.fontWeight = FontWeight.Bolder; } // 检查斜体 if (tag.includes('font-style: italic')) { Style.fontStyle = FontStyle.Italic; } // 检查删除线 if (tag.includes('text-decoration-line: line-through') ) { Style.decoration = { type: TextDecorationType.LineThrough }; } // 检查下划线 if (tag.includes('text-decoration-line: underline') ) { Style.decoration = { type: TextDecorationType.Underline }; } // 添加文本到RichEditor textStyle.style = Style; this.controller.addTextSpan(textContent,textStyle); console.log('打印一下回显数据',textContent,textStyle) } } // 普通文本 else { this.controller.addTextSpan(tag); } }}同时通过控制器进行添加得放到RichEditor生命周期里面onready,不然可能会出现控制器未初始化,无法回显RichEditor(this.options) .onSelect((value: RichEditorSelection) => { this.start = value.selection[0]; this.end = value.selection[1]; }) .onReady(async (controller) => { // 编辑的时候先获取详情 if (this.announceId) { await this.getNoticeDetail() } })最后回显效果如下
-
关键技术难点总结使用画布进行气泡的绘制、高德地图api的使用1.1问题说明在app内嵌入高德地图,根据经纬度在地图上进行打点,自定义打点图标,点击图标显示自定义气泡,气泡触发的的点击事件,地图缩放,禁止旋转等 1.2原因分析初始化配置复杂高德地图 API 提供了丰富的初始化参数,如初始中心点坐标、缩放级别、倾斜角度等。正确配置这些参数以确保地图在 app 启动时能够准确、快速地加载并显示预期内容。错误的配置可能导致地图显示异常,如位置偏移、缩放不流畅等问题,影响用户体验。自定义气泡和自定义打点图标开发与设计稿相同的样式需要使用自定义打点图标,气泡使用常规的column、row等很难达到预期效果。 1.3解决思路使用三方库@amap/amap_lbs_map3d进行地图的嵌入,根据提供的api进行相应的地图操作使用本地图片渲染打点图标,使用canvas进行气泡绘制1.4解决方案1.4解决方案1.4.1声明网络权限"requestPermissions": [ { "name": 'ohos.permission.INTERNET', }]1.4.2在oh-package.json5文件中添加依赖从ohpm仓库获取高德地图包"dependencies": { "@amap/amap_lbs_common": ">=1.2.0", "@amap/amap_lbs_map3d": ">=2.2.0"}1.4.3初始化地图容器1.从高德地图包中导入所需模块import { AMap, MapsInitializer, MapView, MapViewComponent, MapViewManager } from '@amap/amap_lbs_map3d';2.设置Key因为只是做一个demo,不需要key(这一步可忽略,如果要上线的话还是要去申请的)MapsInitializer.setApiKey("您的key");3.获取MapViewMapViewManager.getInstance().registerMapViewCreatedCallback((mapview?: MapView, mapViewName?: string) => { if (!mapview) { return; } let mapView = mapview;})4.初始化地图并获取AMap对象mapView.onCreate();mapView.getMapAsync((map) => {let aMap: AMap = map;aMap.setTrafficEnabled(true) //打开交通路况图层})5.地图组件配置MapViewComponent().width('100%').height('100%')主要结构:@Entry@Componentstruct Index {privatemarkers: ArrayList<Marker> = newArrayList<Marker>()privatemapView?: MapViewprivateaMap?: AMap@StateinfoTitle: string = ''@StatecustomPopup: boolean = falseprivate isMarkerClicked = false@StateviewWidth: number = 0@StateviewHeight: number = 0@StatemapBean:mapType[] = [{name:'万事',lat:39.992520,lon:116.336170},{name:'如意',lat:40.02380181476392,lon:116.43124537956452}]privatemapViewCreateCallback: MapViewCreateCallback = (mapview?: MapView) => { if (!mapview) { return } this.mapView = mapview mapview.onCreate() mapview.getMapAsync((map) => { this.aMap = map this.onMapReload() this.aMap.getUiSettings()?.setRotateGesturesEnabled(false) //禁用旋转手势 //自定义弹出气泡样式 this.aMap.setInfoWindowAdapter(() => { this.popupBuilder() }) this.aMap.setOnMarkerClickListener((marker: Marker): boolean => { this.isMarkerClicked = true this.customPopup = true this.infoTitle = marker.getTitle() returnfalse }) this.aMap?.setOnMapClickListener((point: LatLng) => { if (this.isMarkerClicked) { // 如果来源是 Marker,重置标志位并跳过隐藏逻辑 this.isMarkerClicked = false return } // 如果点击地图空白区域,且 InfoWindow 显示,则隐藏 this.customPopup = false }) }) }aboutToAppear() { MapViewManager.getInstance().registerMapViewCreatedCallback(this.mapViewCreateCallback) }build() { Column(){ MapViewComponent() .width('100%') .height('50%') } }onMapReload() { this.addMarks() }privateasyncaddMarks() { if (this.mapBean) { for (let index = 0; index < this.mapBean.length; index++) { const element = this.mapBean[index] this.addMark(element) } } }addMark(element: mapType) { BitmapDescriptorFactory.fromView(() => { this.customMarkerBuilder() }).then((bitmapDes: BitmapDescriptor | undefined) => { letoptions: MarkerOptions = newMarkerOptions() .setPosition(newLatLng(element.lat, element.lon)) .setIcon(bitmapDes) .setTitle(element.name) let mark = this.aMap?.addMarker(options) this.markers.add(mark) }) }//自定义弹窗信息@BuilderpopupBuilder() { if (this.customPopup){ Column() { Stack() { SkiingMapBubble().width(this.viewWidth + 5).height(this.viewHeight + 6) Column() { Text(this.infoTitle) .fontSize(13) .margin({ left: 10, top: 3, right: 10, bottom: 9 }) } .borderRadius(6) .onAreaChange((oldValue: Area, newValue: Area) => { this.viewHeight = newValue.heightasnumber this.viewWidth = newValue.widthasnumber }) } .onClick(() => { promptAction.showToast({ message: '气泡的点击事件', duration: 2000, backgroundColor: Color.Black, textColor: Color.White, backgroundBlurStyle: BlurStyle.NONE, alignment: Alignment.Center }) }) Image($r('app.media.select')) .width(40) .height(40) } .margin({ bottom: -33 }) } }@BuildercustomMarkerBuilder() { Column() { Image($r('app.media.yqk')).width('100%').height(('100%')) } .width(25) .height(25) }}实现效果:
-
需求:图片双指捏合放大缩小,或者双击放大缩小问题:放大之后会覆盖到状态栏解决方案:放在list中 @Entry@ComponentV2struct ImagePreviewPage { build() { Column() { // ImagePreviewView() List(){ ListItem(){ ImagePreviewView() } } } //放大如果需要覆盖状态栏, // .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) .height('100%') .width('100%') }}
-
**核心技术难点:Tab与滚动同步机制** 一、关键技术难点总结在复杂的移动应用场景中,需要实现动态Tab页面切换和滚动同步的交互效果。这种交互模式广泛应用于电商分类页面、新闻资讯应用、功能导航界面等场景,要求用户能够通过点击Tab快速跳转到对应内容区域的锚点位置,同时支持手动滚动浏览,两种操作方式需要完美协调。 1 问题说明1.1 核心交互需求- **Tab锚点跳转**:用户点击Tab标签时,页面需要精确滚动到对应内容区域的锚点位置- **滚动同步指示**:用户手动滚动时,Tab指示器需要实时反映当前位置- **高度自适应**:不同Tab内容长度差异巨大,需要动态计算和适配- **流畅动画**:Tab切换和滚动过程需要平滑的动画效果 1.2 核心挑战包括:- 动态内容高度计算与Tab锚点跳转的精确同步- 复杂滚动交互中的状态管理- 不同设备屏幕尺寸的适配- 滚动动画与Tab指示器的实时联动- 大量动态内容的性能优化和内存管理 2 原因分析 2.1 Tab内容高度动态变化,无法预知准确高度- 每个Tab包含不同数量的内容项,导致内容高度差异巨大- 传统固定高度计算方式无法适应动态内容变化- 滚动位置与Tab索引无法精确对应,用户体验差 2.2 滚动状态与Tab状态不同步- 用户手动滚动时,Tab指示器无法准确反映当前位置- Tab点击切换时,滚动位置计算不准确,可能出现跳转错误- 滚动动画过程中状态管理混乱,导致交互异常 2.3 设备屏幕尺寸差异导致适配困难- 不同设备屏幕高度不同,需要动态计算可视区域- 底部填充高度需要根据实际内容动态调整- 固定像素值无法适应各种屏幕尺寸 3 解决思路 - **双重高度计算策略:** 结合预计算高度和实时测量高度,确保准确性- **滚动状态机管理:** 通过状态标志防止滚动过程中的冲突操作- **动态填充计算:** 根据屏幕尺寸和内容高度动态调整底部填充 3.1 页面结构定义- 使用`Tabs`组件实现顶部Tab导航栏,通过`onTabBarClick`监听Tab切换事件- 通过`Scroll`组件包装内容区域,绑定`onScroll`事件实现滚动位置监听- 每个Tab内容使用`@Builder`方法构建,并绑定`onAreaChange`监听实际渲染高度- 关键状态变量:`currentTabIndex`(当前Tab)、`tabHeights`(预计算高度)、`actualTabHeights`(实际高度)、`isScrolling`(滚动状态) 3.2 生命周期管理- `aboutToAppear()`:初始化屏幕信息,获取动态内容数据- 数据加载完成后,立即计算Tab高度数组和底部填充- 通过`onAreaChange`监听每个Tab内容的实际渲染高度- 实时更新高度数组,确保滚动计算的准确性 3.3 核心方法职责- `calculateTabHeight()`:基于数据内容预计算Tab高度- `scrollToTab()`:处理Tab锚点跳转,计算目标滚动位置- `onScroll()`:监听滚动事件,更新当前Tab索引- `calculateBottomPadding()`:动态计算底部填充,适配不同屏幕- `onTabChange()`:Tab切换事件处理,协调Tab状态和滚动位置 4 解决方案 核心机制:Tab锚点跳转与滚动双向同步 4.1 双重高度计算策略 采用预计算高度和实时测量高度相结合的方式,确保Tab高度计算的准确性: ```typescript// 预计算高度:基于数据内容计算calculateTabHeight(tabData: object): number { // 标题高度:24px上边距 + 18px字体 + 16px下边距 = 58px const titleHeight: number = 58; // 获取内容列表 const contentList: object[] = tabData['List'] || []; if (contentList.length === 0) { return titleHeight; } // 计算网格行数:每行4列 const columnsPerRow: number = 4; const rows: number = Math.ceil(contentList.length / columnsPerRow); // 每个内容项高度:图标28px + 文字13px + 上边距8px + 下边距16px = 65px const itemHeight: number = 65; // 网格间距:每行之间16px const rowGutter: number = 16; // 总高度:标题 + 网格内容 const gridHeight: number = rows * itemHeight + (rows - 1) * rowGutter; const totalHeight: number = titleHeight + gridHeight; return totalHeight;} // 实时测量高度:通过onAreaChange获取实际渲染高度.onAreaChange((oldValue: Area, newValue: Area) => { if (newValue.height > 0) { this.actualTabHeights[tabIndex] = newValue.height as number; }})``` 4.2 滚动状态机管理 通过状态标志防止滚动过程中的冲突操作,确保Tab切换和手动滚动的协调: ```typescript// 滚动状态管理@State isScrolling: boolean = false; // 滚动到指定tabscrollToTab(index: number) { if (index < 0 || index >= this.tabHeights.length) { return; } this.isScrolling = true; // 设置滚动状态 // 计算目标滚动位置 let targetOffset: number = 0; for (let i: number = 0; i < index; i++) { const actualHeight = this.getActualTabHeight(i); targetOffset += actualHeight; } // 执行滚动动画 this.scrollController.scrollTo({ xOffset: 0, yOffset: targetOffset, animation: { duration: 300, curve: Curve.EaseInOut } }); // 滚动动画完成后重置状态 setTimeout(() => { this.isScrolling = false; }, 350);} // 处理滚动事件,更新当前tabonScroll(xOffset: number, yOffset?: number) { if (this.isScrolling) { return; // 防止滚动动画过程中的状态冲突 } // 计算当前滚动位置对应的tab索引 let scrollY: number = this.scrollY + (yOffset ?? 0); this.scrollY = scrollY; let currentOffset: number = 0; for (let i: number = 0; i < this.tabHeights.length; i++) { const tabHeight: number = this.getActualTabHeight(i); const tabStart: number = currentOffset; const tabEnd: number = currentOffset + tabHeight; if (scrollY >= tabStart && scrollY < tabEnd) { if (this.currentTabIndex !== i) { this.currentTabIndex = i; // 更新当前tab索引 } break; } currentOffset += tabHeight; }}``` 4.3 动态填充计算 根据屏幕尺寸和内容高度动态调整底部填充,确保滚动体验的完整性: ```typescript// 计算底部填充高度calculateBottomPadding(): void { if (!this.contentList || this.contentList.length === 0) { return; } // 计算可视区域高度 const visibleHeight: number = this.screenHeight - this.navBarHeight - this.tabsHeight; // 基于数据内容计算每个tab的高度 for (let i = 0; i < this.contentList.length; i++) { const tabData = this.contentList[i]; const tabHeight = this.calculateTabHeight(tabData); this.tabHeights[i] = tabHeight; } // 获取最后一块内容的高度 const lastIndex: number = this.tabHeights.length - 1; const lastTabHeight: number = this.tabHeights[lastIndex] || 0; // 精确的填充计算:确保滚动体验 let neededPadding: number = visibleHeight - vp2px(lastTabHeight) - 122; neededPadding = neededPadding > 0 ? neededPadding : 0; this.bottomPaddingHeight = neededPadding + 'px';}``` 4.4 Tab锚点跳转与滚动联动 实现Tab点击切换与滚动位置的双向绑定,确保用户操作的一致性: ```typescript// 处理tab切换onTabChange(index: number) { if (this.isScrolling) { return; // 防止滚动过程中的重复操作 } this.currentTabIndex = index; this.scrollToTab(index); // 滚动到对应位置} // Tab点击事件绑定.onTabBarClick((index: number) => { this.onTabChange(index);})```
-
开发者技术支持-集成DeepSeekR1模型遇到的问题1、问题说明在代码中集成deepseek模型并实现流式输出的demo中遇到的一些问题:明文硬编码 api_url、 token(高风险)SSE 请求参数不规范,可能造成不返回或中断。只发送当前一句,无对话上下文,回答易“断层”。2、原因分析安全方面密钥与 API放在客户端,任何人均可抓包获取。协议方面:fetch event source 一般使用 headers 和 body(USON字符串),而不是 header/extraData;method 需用POST字符串。3、解决思路安全:使用后端代理请求(隐藏密钥),前端只调用代理;若必须前端直连也应把 token 抽离到本地安全存储并做最小化权限。APl:按 fetch event source 标准使用 headers 和 body:JsoN.stringify(payload), method:'PosT'。流处理:保留换行;识别并在“[DONE]”时结束;健壮的JSON 解析与错误提示4、解决方案重写promptdeepseek方法async promptDeepSeek() { const payload = { model: this.show ? 'DeepSeek-R1' : 'DeepSeek-V3', max_tokens: 500, messages: this.buildContextMessages(), stream: true, stream_options: { include_usage: true }, temperature: this.show ? 1.0 : 0.7 }; // 占位的 assistant 条目,后续增量拼接 this.listAA.push({ role: 'assistant', content: '' }); try { await fetchEventSource(this.api_url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.token}` }, body: JSON.stringify(payload), onopen: async () => { // 可在此做 UI 状态更新,如禁用发送按钮等 }, onmessage: (ev) => { const data = ev?.data; if (!data) return; if (data === '[DONE]') { // 流结束 return; } try { const json = JSON.parse(data) as ResponseQ; const chunk = json?.choices?.[0]?.delta?.content ?? ''; this.appendAssistantDelta(chunk); } catch { } }, onclose: () => { // 可在此恢复 UI 状态 }, onerror: (err) => { promptAction.showToast({ message: '生成失败,请稍后再试' }); throw err; } }); } catch (_) { }}前端改为自建代理import express from 'express';import fetch from 'node-fetch';const app = express();app.use(express.json());const API_URL = 'https://maas-cn-southwest-2.modelarts-maas.com/v1/infers/8a062fd4-7367-4ab4-a936-5eeb8fb821c4/v1/chat/completions';app.post('/api/chat', async (req, res) => { const resp = await fetch(API_URL, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.DEEPSEEK_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify(req.body) }); // 透传流式响应 res.status(resp.status); resp.body.pipe(res);});app.listen(3000, () => console.log('proxy on :3000'));
-
问题说明在使用lazyForEach + Swiper 进行音视频功能开发时,当对数据源进行新加入一条新的数据时,无法正常完成播放功能原因分析在对数据源进行操作时,可能直接对数组进行push一条新的record继承接口IDataSource时,没有在对数据操作后调用数据刷新解决思路1、 检查对数据进行操作时,是否直接操作了数组,代码如下:// 此处假设this.data继承了接口IDataSource this.data.list.push(...args) 上述代码中,直接对列表进行了增加一条数据操作,因此,不会引起lazyForEach刷新数据,因此需要调用IDataSource接口中实现的方法,完成数据的添加,代码如下:// 假设this.data继承了IDataSource this.data.push(item) 对应实现的IDataSource接口部分代码如下:// BaseSource export class BaseSource<T> implements IDataSource { //... // 通知数据变化 private notifyDataAdd(index: number): void { this.listeners.forEach(listener => { listener.onDataAdd(index) }) } pushData(data: ListItem): void { this.dataArray.push(data) this.notifyDataAdd(this.dataArray.length - 1) } } 参考链接:[https://developer.huawei.com/consumer/cn/blog/topic/03186153330902043](鸿蒙HarmonyOS ArkTS LazyForEach懒加载渲染控制详解)解决方案使用实现的push()方法对列表操作完善继承自IDataSource接口的类
-
1.问题说明:创建window弹框,一般使用如下apihttps://developer.huawei.com/consumer/cn/doc/harmonyos-references/arkts-apis-window-windowstage#createsubwindow9但是无法满足一些拓展,比如:1)window弹框想要关闭时,父页面(启动window弹框的页面)感知不到2)控制弹框背景是否需要蒙层3)每次创建都要调用系统API,不方便管理window窗口、且重复代码较多4)父页面给window传递参数,使用系统api的方法,无法传参 2.原因分析:没有window页面容器,无法加载自定义业务布局 3.解决思路:封装window弹框工具类:创建window容器,由容器接收各种各样的数据后,加载@Component业务布局、透传业务参数、回调返回监听事件4.解决方案:业务仅需调用如下代码创建子window:await WindowConfig.showSubWindow({ // 自定义子window页面 customComponent: wrapBuilder(WindowBuilder1), // window名称 subWindowName: 'window1', // 显示蒙层 isShowMaskLayer: true, windowParams: "我是window传入的参数", onBackPress: (windowName) => { console.log('window弹窗关闭了: ' + windowName) WindowConfig.removeSubWindow('window1') } }) 其中自定义@Component,例如:WindowBuilder1import { SubWindowInfo } from "../../utils/window/WindowConfig"@Builderexport function WindowBuilder1(info: SubWindowInfo) { WindowComponent1({ info: info })}/** * 业务页面内容 */@Componentstruct WindowComponent1 { @Prop info: SubWindowInfo build() { Column() { Text(this.info.windowParams).margin({ bottom: 30 }).fontColor(Color.White) } .width('100%') .height('30%') .justifyContent(FlexAlign.End) .backgroundColor(Color.Gray) }} 封装window容器和创建、关闭window方法import { window } from '@kit.ArkUI'import { common } from '@kit.AbilityKit'const SubWindowInfos = "SubWindowInfo"export class WindowConfig { /** * 创建子window * @param info 需要需要自定义window的数据: window名称、window自定义页面、需要传入window的参数 * @returns 待子window创建完成后返回空 */ static async showSubWindow(info: SubWindowInfo): Promise<void> { try { let storage: LocalStorage = new LocalStorage() // 将自定义window的数据存入storage,待window容器加载、解析 storage.setOrCreate(SubWindowInfos, info) let context = getContext() as common.UIAbilityContext; let subWindow = await context.windowStage.createSubWindow(info.subWindowName ?? 'SubWindowRootName') await (subWindow as window.Window).loadContentByName('SubWindowPage', storage) await subWindow.showWindow() subWindow.setWindowBackgroundColor("#00000000") } catch (err) { } } static async removeSubWindow(subWindowName: string) { try { let windowFrame: window.Window | undefined = window.findWindow(subWindowName); await windowFrame?.destroyWindow() } catch (err) { } }}/** * window容器 */@Entry({ routeName: 'SubWindowPage', storage: LocalStorage.getShared() })@Componentstruct WindowContainer { @LocalStorageProp(SubWindowInfos) subWindowInfos?: SubWindowInfo = undefined onBackPress(): boolean | void { this.subWindowInfos?.onBackPress?.(this.subWindowInfos.subWindowName ?? "") return false } build() { if (this.subWindowInfos != undefined) { Stack() { Column() { } .width("100%") .height("100%") .backgroundColor(this.subWindowInfos.isShowMaskLayer ? "#33000000" : "#00000000") // 加载自定义页面 this.subWindowInfos.customComponent.builder(this.subWindowInfos) }.width("100%").height("100%").backgroundColor(Color.Transparent).align(Alignment.Bottom) } }}/** * 子window参数 */export interface SubWindowInfo { // window名称 subWindowName?: string // window自定义页面 customComponent: WrappedBuilder<SubWindowInfo[]> // 需要传入window的参数 windowParams: ESObject // 返回事件监听 onBackPress?: (subWindowName: string) => void // 是否显示蒙层 isShowMaskLayer?: boolean} 5. 效果图:
-
1.问题说明:鸿蒙原生路由的全局管理、生命周期管理等实际开发问题2.原因分析:每个页面都要做全局的监听、生命周期的管理、跳转等业务管理3.解决思路:原生路由:创建全局基础路由容器,每个页面都是使用路由容器做基础底座生命周期:全局基础路由容器中,在NavDestination的生命周期函数中进行闭包回调,这样每个使用容器的页面都可以接收到页面生命周期函数的回调,解决生命周期统一管理和单页码使用的问题路由管理:创建路由管理类,封装NavPathStack的跳转等函数4.解决方案:一、路由管理类的封装export class SHRouterRule { static readonly pathStack: NavPathStack = new NavPathStack(); static pushName(name: string, params?: ESObject) { SHRouterRule.pathStack.pushPathByName(name, params) } static pop() { SHRouterRule.pathStack.pop() } static removeByName(name: string) { SHRouterRule.pathStack.removeByName(name) } static replaceName(name: string, params?: ESObject) { SHRouterRule.pathStack.replacePathByName(name, params) } static pushDestination(name: string, params?: ESObject, options?: NavigationOptions) { SHRouterRule.pathStack.pushDestination({ name: name, param: params }, { launchMode: LaunchMode.MOVE_TO_TOP_SINGLETON }) }}二、使用router_map.json配置全局路由表1.工程配置文件module.json5中配置 {"routerMap": "$profile:router_map"}。2.router_map.json中配置全局路由表,导航控制器NavPathStack可根据路由表中的name将对应页面信息入栈。例如:{ "routerMap": [ { "name": "NavComponent", "pageSourceFile": "src/main/ets/pages/NavComponent.ets", "buildFunction": "PageBuilder", "data": { "description": "this is NavComponent" } } ]}三、全局路由容器的封装@Componentexport struct NavContainer { onShown?: () => void onHidden?: () => void @BuilderParam contentBuilder: () => void // 可以做全局每一个界面的订阅(通知)注册 navOnShown() { console.log("NavContainer===navOnShown") if (this.onShown) { this.onShown() } } // 可以做全局每一个界面的订阅(通知)取消 navOnHidden() { console.log("NavContainer===navOnHidden") if (this.onHidden) { this.onHidden() } } build() { NavDestination() { this.contentBuilder() } .hideTitleBar(true) .width('100%') .height('100%') .onShown(() => { this.navOnShown() }) .onHidden(() => { this.navOnHidden() }) }}四、单页面路由容器的使用import { NavContainer } from './NavContainer'import { SHRouterRule } from 'shrouter'@Builderexport function PageBuilder(name: string, param: ESObject) { NavComponent()}@Componentstruct NavComponent { aboutToAppear(): void { console.log("NavComponent===aboutToAppear") } aboutToDisappear(): void { console.log("NavComponent===aboutToDisappear") } // 可以做单个界面的订阅(通知)注册 navOnShown() { console.log("NavComponent======navOnShown") } // 可以做单个界面的订阅(通知)取消 navOnHidden() { console.log("NavComponent======navOnHidden") } build() { NavContainer({ onShown: () => { this.navOnShown() }, onHidden: () => { this.navOnHidden() }, contentBuilder: () => { this.contentBuilder() } }) } @Builder contentBuilder() { Column() { Text("跳转事件") .backgroundColor(Color.Green) .textAlign(TextAlign.Center) .width(100) .height(100) .onClick(() => { SHRouterRule.pushName('NavComponent') }) } .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .width('100%') .height('100%') }}五、鸿蒙APP的入口Index.ets文件配置build() { Navigation(SHRouterRule.pathStack) { } .titleMode(NavigationTitleMode.Mini) .hideTitleBar(true) .hideBackButton(true) .hideToolBar(true) .width('100%')}
上滑加载中
推荐直播
-
HDC深度解读系列 - Serverless与MCP融合创新,构建AI应用全新智能中枢2025/08/20 周三 16:30-18:00
张昆鹏 HCDG北京核心组代表
HDC2025期间,华为云展示了Serverless与MCP融合创新的解决方案,本期访谈直播,由华为云开发者专家(HCDE)兼华为云开发者社区组织HCDG北京核心组代表张鹏先生主持,华为云PaaS服务产品部 Serverless总监Ewen为大家深度解读华为云Serverless与MCP如何融合构建AI应用全新智能中枢
回顾中 -
关于RISC-V生态发展的思考2025/09/02 周二 17:00-18:00
中国科学院计算技术研究所副所长包云岗教授
中科院包云岗老师将在本次直播中,探讨处理器生态的关键要素及其联系,分享过去几年推动RISC-V生态建设实践过程中的经验与教训。
回顾中 -
一键搞定华为云万级资源,3步轻松管理企业成本2025/09/09 周二 15:00-16:00
阿言 华为云交易产品经理
本直播重点介绍如何一键续费万级资源,3步轻松管理成本,帮助提升日常管理效率!
回顾中
热门标签