HBuilderX

HBuilderX

极客开发工具
uni-app

uni-app

开发一次,多端覆盖
uniCloud

uniCloud

云开发平台
HTML5+

HTML5+

增强HTML5的功能体验
MUI

MUI

上万Star的前端框架

【报Bug】uni @touchmove 事件与 下拉刷新事件冲突

touchmove

【报Bug】uni @touchmove 事件与 下拉刷新事件冲突

这么多年的bug了为啥不解决呢, 很影响体验啊。

【报Bug】uni @touchmove 事件与 下拉刷新事件冲突

这么多年的bug了为啥不解决呢, 很影响体验啊。

UniApp开发鸿蒙应用实践:从底部小白条到代理提醒的完整解决方案

鸿蒙next 鸿蒙征文

前言

随着鸿蒙生态的快速发展,越来越多的开发者开始关注如何快速将现有应用适配到鸿蒙平台。UniApp作为一个成熟的跨平台开发框架,已经支持了鸿蒙应用的开发。然而在实际开发过程中,我们会遇到一些平台特有的问题和挑战。本文将基于一个服药提醒应用的开发实践,详细介绍如何解决鸿蒙平台的底部安全区适配问题,以及如何使用UTS插件实现代理提醒功能。

一、底部小白条问题的解决方案(SafeArea适配)

1.1 问题背景

在鸿蒙设备上,特别是全面屏设备,底部通常会有一个用于手势导航的"小白条"区域。这个区域被称为安全区域(Safe Area),如果应用界面没有正确处理这个区域,会导致底部内容被遮挡,影响用户体验。

1.2 SafeArea配置方案

UniApp为鸿蒙平台提供了完善的安全区域配置方案。我们需要在manifest.json文件中进行配置:

{  
  "app-harmony": {  
    "safearea": {  
      "background": "#ffffff",  
      "backgroundDark": "#2f0508",  
      "bottom": {  
        "offset": "none"  
      }  
    }  
  }  
}

配置说明:

  • background: 浅色模式下安全区域的背景色,这里设置为白色#ffffff,与应用主题保持一致
  • backgroundDark: 深色模式下安全区域的背景色,设置为深红色#2f0508,适配暗黑主题
  • bottom.offset: 底部区域占位策略,设置为"none"表示在没有TabBar时不需要占位

我们成功解决了底部小白条的适配问题,确保应用在各种鸿蒙设备上都能正常显示和交互。

二、代理提醒功能的UTS插件实现

2.1 代理提醒简介

代理提醒(Reminder Agent)是鸿蒙系统提供的一项重要能力,允许应用在后台设置定时提醒,即使应用被关闭也能准时触发。这对于服药提醒、日程管理等场景至关重要。

鸿蒙系统的代理提醒支持三种类型:

  • 闹钟提醒(ALARM): 基于时间的周期性提醒,适合每天固定时间的场景
  • 日历提醒(CALENDAR): 基于日期的事件提醒,适合特定日期的事件
  • 倒计时提醒(TIMER): 基于时长的一次性提醒,适合短期计时场景

2.2 为什么需要UTS插件

UniApp虽然支持鸿蒙平台,但并未内置代理提醒的API。要使用鸿蒙的原生能力,我们需要通过UTS(UniApp TypeScript)插件来调用鸿蒙的原生API。

UTS插件的优势:

  • 类型安全: 基于TypeScript,提供完整的类型检查
  • 性能优秀: 编译为原生代码,性能接近原生开发
  • 开发便捷: 语法接近TypeScript,学习成本低
  • 跨平台: 可以为不同平台编写不同的实现

2.3 UTS插件项目结构

我们创建了一个名为uni-reminder的UTS插件,项目结构如下:

uni_modules/  
└── uni-reminder/  
    ├── utssdk/  
    │   ├── app-harmony/        # 鸿蒙平台实现  
    │   │   └── index.uts       # 主要实现文件  
    │   └── interface.uts       # 接口定义文件  
    ├── package.json  
    └── readme.md

关键文件说明:

  • interface.uts: 定义插件的接口类型,包括所有方法的参数和回调类型
  • app-harmony/index.uts: 鸿蒙平台的具体实现,调用鸿蒙原生API

2.4 接口定义(interface.uts)

首先,我们需要定义清晰的接口类型。以闹钟提醒为例:

// 闹钟提醒选项  
export type PublishAlarmReminderOptions = {  
  hour: number                    // 闹钟小时数(0-23)  
  minute: number                  // 闹钟分钟数(0-59)  
  daysOfWeek?: Array<number> | null  // 重复日期,周日=0  
  title?: string | null           // 提醒标题  
  content?: string | null         // 提醒内容  
  success?: PublishAlarmReminderSuccessCallback | null  
  fail?: PublishAlarmReminderFailCallback | null  
  complete?: PublishAlarmReminderCompleteCallback | null  
}  

// 成功回调返回值  
export type PublishAlarmReminderSuccess = {  
  errMsg: string  
  reminderId: number  // 提醒ID,用于后续取消  
}  

// 定义uni对象上的方法  
export interface Uni {  
  publishAlarmReminder(options: PublishAlarmReminderOptions): void  
  publishCalendarReminder(options: PublishCalendarReminderOptions): void  
  publishTimerReminder(options: PublishTimerReminderOptions): void  
  cancelReminder(options: CancelReminderOptions): void  
  cancelAllReminders(options: CancelAllRemindersOptions): void  
  getValidReminders(options: GetValidRemindersOptions): void  
}

这种接口定义方式遵循了UniApp的API设计规范,使用success/fail/complete回调模式,保持了与UniApp其他API的一致性。

2.5 鸿蒙平台核心实现

2.5.1 导入鸿蒙原生API

import reminderAgentManager from '@ohos.reminderAgentManager'  
import { BusinessError } from '@kit.BasicServicesKit'
  • reminderAgentManager: 鸿蒙的代理提醒管理器
  • BusinessError: 鸿蒙的业务错误类型

2.5.2 实现闹钟提醒

export function publishAlarmReminder(options: PublishAlarmReminderOptions): void {  
  try {  
    // 构建闹钟提醒请求对象  
    const requestObj = {  
      reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_ALARM,  
      hour: options.hour,  
      minute: options.minute,  
      title: options.title || '闹钟提醒',  
      content: options.content || '该起床了',  
      wantAgent: {  
        pkgName: 'com.liudd1.anshichiyao',  
        abilityName: 'EntryAbility'  
      },  
      actionButton: [{  
        title: '关闭',  
        type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_CLOSE  
      }]  
    } as UTSJSONObject  

    // 设置重复日期  
    if (options.daysOfWeek && options.daysOfWeek.length > 0) {  
      requestObj['daysOfWeek'] = options.daysOfWeek  
    }  

    // 发布提醒  
    reminderAgentManager.publishReminder(  
      requestObj as unknown as reminderAgentManager.ReminderRequest  
    )  
      .then((reminderId: number) => {  
        options?.success?.({  
          errMsg: 'publishAlarmReminder:ok',  
          reminderId: reminderId  
        })  
        options?.complete?.({ errMsg: 'publishAlarmReminder:ok' })  
      })  
      .catch((err: BusinessError) => {  
        options?.fail?.({ errMsg: `publishAlarmReminder:fail ${err.message}` })  
        options?.complete?.({ errMsg: `publishAlarmReminder:fail ${err.message}` })  
      })  
  } catch (err) {  
    const error = err as BusinessError  
    options?.fail?.({ errMsg: `publishAlarmReminder:fail ${error.message}` })  
    options?.complete?.({ errMsg: `publishAlarmReminder:fail ${error.message}` })  
  }  
}

实现要点:

  1. reminderType: 指定提醒类型为闹钟
  2. wantAgent: 配置点击提醒后要启动的应用和页面
  3. actionButton: 添加操作按钮
  4. daysOfWeek: 可选的重复日期数组
  5. 错误处理: 使用try-catch和Promise.catch双重错误处理

2.5.3 实现日历提醒

export function publishCalendarReminder(options: PublishCalendarReminderOptions): void {  
  try {  
    const date = new Date(options.dateTime)  

    const requestObj = {  
      reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,  
      dateTime: {  
        year: date.getFullYear(),  
        month: date.getMonth() + 1,  
        day: date.getDate(),  
        hour: date.getHours(),  
        minute: date.getMinutes()  
      },  
      title: options.title || '日历提醒',  
      content: options.content || '事件提醒',  
      wantAgent: {  
        pkgName: 'com.liudd1.anshichiyao',  
        abilityName: 'EntryAbility'  
      },  
      actionButton: [{  
        title: '关闭',  
        type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_CLOSE  
      }]  
    } as UTSJSONObject  

    // 设置重复月份和日期  
    if (options.repeatMonths && options.repeatMonths.length > 0) {  
      requestObj['repeatMonths'] = options.repeatMonths  
    }  
    if (options.repeatDays && options.repeatDays.length > 0) {  
      requestObj['repeatDays'] = options.repeatDays  
    }  

    // 发布提醒(Promise处理逻辑同闹钟提醒)  
    // ...  
  } catch (err) {  
    // 错误处理  
  }  
}

2.5.4 实现倒计时提醒

export function publishTimerReminder(options: PublishTimerReminderOptions): void {  
  try {  
    const requestObj = {  
      reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_TIMER,  
      triggerTimeInSeconds: options.triggerTimeInSeconds,  
      title: options.title || '倒计时提醒',  
      content: options.content || '倒计时已到',  
      wantAgent: {  
        pkgName: 'com.liudd1.anshichiyao',  
        abilityName: 'EntryAbility'  
      },  
      actionButton: [{  
        title: '关闭',  
        type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_CLOSE  
      }]  
    } as UTSJSONObject  

    // 发布提醒  
    // ...  
  } catch (err) {  
    // 错误处理  
  }  
}

2.6 权限配置

使用代理提醒功能需要在manifest.json中声明权限:

{  
  "app-harmony": {  
    "permissions": [  
      "ohos.permission.PUBLISH_AGENT_REMINDER"  
    ]  
  }  
}

同时配合通知权限插件请求用户授权:

uni.requestNotification({  
  success: (res) => {  
    if (res.granted) {  
      console.log('通知权限已获取')  
    }  
  }  
})

2.7 在应用中使用插件

插件开发完成后,在应用中的使用非常简单:

export default {  
  data() {  
    return {  
      reminderIds: []  
    }  
  },  
  methods: {  
    // 设置每天早上8点的服药提醒  
    setMedicineReminder() {  
      uni.publishAlarmReminder({  
        hour: 8,  
        minute: 0,  
        daysOfWeek: [1, 2, 3, 4, 5, 6, 0],  
        title: '服药提醒',  
        content: '该吃药了,记得按时服药哦!',  
        success: (res) => {  
          console.log('提醒设置成功,ID:', res.reminderId)  
          this.reminderIds.push(res.reminderId)  
          uni.showToast({ title: '提醒设置成功' })  
        },  
        fail: (err) => {  
          console.error('提醒设置失败:', err.errMsg)  
        }  
      })  
    },  

    // 设置30分钟后的倒计时提醒  
    setTimerReminder() {  
      uni.publishTimerReminder({  
        triggerTimeInSeconds: 30 * 60,  
        title: '服药倒计时',  
        content: '30分钟已到,该服药了',  
        success: (res) => {  
          this.reminderIds.push(res.reminderId)  
        }  
      })  
    },  

    // 取消所有提醒  
    cancelAllReminders() {  
      uni.cancelAllReminders({  
        success: () => {  
          this.reminderIds = []  
          uni.showToast({ title: '已清空所有提醒' })  
        }  
      })  
    }  
  }  
}

希望本文的分享能够帮助到正在开发鸿蒙应用的开发者们,让我们一起为鸿蒙生态的繁荣贡献力量!

继续阅读 »

前言

随着鸿蒙生态的快速发展,越来越多的开发者开始关注如何快速将现有应用适配到鸿蒙平台。UniApp作为一个成熟的跨平台开发框架,已经支持了鸿蒙应用的开发。然而在实际开发过程中,我们会遇到一些平台特有的问题和挑战。本文将基于一个服药提醒应用的开发实践,详细介绍如何解决鸿蒙平台的底部安全区适配问题,以及如何使用UTS插件实现代理提醒功能。

一、底部小白条问题的解决方案(SafeArea适配)

1.1 问题背景

在鸿蒙设备上,特别是全面屏设备,底部通常会有一个用于手势导航的"小白条"区域。这个区域被称为安全区域(Safe Area),如果应用界面没有正确处理这个区域,会导致底部内容被遮挡,影响用户体验。

1.2 SafeArea配置方案

UniApp为鸿蒙平台提供了完善的安全区域配置方案。我们需要在manifest.json文件中进行配置:

{  
  "app-harmony": {  
    "safearea": {  
      "background": "#ffffff",  
      "backgroundDark": "#2f0508",  
      "bottom": {  
        "offset": "none"  
      }  
    }  
  }  
}

配置说明:

  • background: 浅色模式下安全区域的背景色,这里设置为白色#ffffff,与应用主题保持一致
  • backgroundDark: 深色模式下安全区域的背景色,设置为深红色#2f0508,适配暗黑主题
  • bottom.offset: 底部区域占位策略,设置为"none"表示在没有TabBar时不需要占位

我们成功解决了底部小白条的适配问题,确保应用在各种鸿蒙设备上都能正常显示和交互。

二、代理提醒功能的UTS插件实现

2.1 代理提醒简介

代理提醒(Reminder Agent)是鸿蒙系统提供的一项重要能力,允许应用在后台设置定时提醒,即使应用被关闭也能准时触发。这对于服药提醒、日程管理等场景至关重要。

鸿蒙系统的代理提醒支持三种类型:

  • 闹钟提醒(ALARM): 基于时间的周期性提醒,适合每天固定时间的场景
  • 日历提醒(CALENDAR): 基于日期的事件提醒,适合特定日期的事件
  • 倒计时提醒(TIMER): 基于时长的一次性提醒,适合短期计时场景

2.2 为什么需要UTS插件

UniApp虽然支持鸿蒙平台,但并未内置代理提醒的API。要使用鸿蒙的原生能力,我们需要通过UTS(UniApp TypeScript)插件来调用鸿蒙的原生API。

UTS插件的优势:

  • 类型安全: 基于TypeScript,提供完整的类型检查
  • 性能优秀: 编译为原生代码,性能接近原生开发
  • 开发便捷: 语法接近TypeScript,学习成本低
  • 跨平台: 可以为不同平台编写不同的实现

2.3 UTS插件项目结构

我们创建了一个名为uni-reminder的UTS插件,项目结构如下:

uni_modules/  
└── uni-reminder/  
    ├── utssdk/  
    │   ├── app-harmony/        # 鸿蒙平台实现  
    │   │   └── index.uts       # 主要实现文件  
    │   └── interface.uts       # 接口定义文件  
    ├── package.json  
    └── readme.md

关键文件说明:

  • interface.uts: 定义插件的接口类型,包括所有方法的参数和回调类型
  • app-harmony/index.uts: 鸿蒙平台的具体实现,调用鸿蒙原生API

2.4 接口定义(interface.uts)

首先,我们需要定义清晰的接口类型。以闹钟提醒为例:

// 闹钟提醒选项  
export type PublishAlarmReminderOptions = {  
  hour: number                    // 闹钟小时数(0-23)  
  minute: number                  // 闹钟分钟数(0-59)  
  daysOfWeek?: Array<number> | null  // 重复日期,周日=0  
  title?: string | null           // 提醒标题  
  content?: string | null         // 提醒内容  
  success?: PublishAlarmReminderSuccessCallback | null  
  fail?: PublishAlarmReminderFailCallback | null  
  complete?: PublishAlarmReminderCompleteCallback | null  
}  

// 成功回调返回值  
export type PublishAlarmReminderSuccess = {  
  errMsg: string  
  reminderId: number  // 提醒ID,用于后续取消  
}  

// 定义uni对象上的方法  
export interface Uni {  
  publishAlarmReminder(options: PublishAlarmReminderOptions): void  
  publishCalendarReminder(options: PublishCalendarReminderOptions): void  
  publishTimerReminder(options: PublishTimerReminderOptions): void  
  cancelReminder(options: CancelReminderOptions): void  
  cancelAllReminders(options: CancelAllRemindersOptions): void  
  getValidReminders(options: GetValidRemindersOptions): void  
}

这种接口定义方式遵循了UniApp的API设计规范,使用success/fail/complete回调模式,保持了与UniApp其他API的一致性。

2.5 鸿蒙平台核心实现

2.5.1 导入鸿蒙原生API

import reminderAgentManager from '@ohos.reminderAgentManager'  
import { BusinessError } from '@kit.BasicServicesKit'
  • reminderAgentManager: 鸿蒙的代理提醒管理器
  • BusinessError: 鸿蒙的业务错误类型

2.5.2 实现闹钟提醒

export function publishAlarmReminder(options: PublishAlarmReminderOptions): void {  
  try {  
    // 构建闹钟提醒请求对象  
    const requestObj = {  
      reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_ALARM,  
      hour: options.hour,  
      minute: options.minute,  
      title: options.title || '闹钟提醒',  
      content: options.content || '该起床了',  
      wantAgent: {  
        pkgName: 'com.liudd1.anshichiyao',  
        abilityName: 'EntryAbility'  
      },  
      actionButton: [{  
        title: '关闭',  
        type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_CLOSE  
      }]  
    } as UTSJSONObject  

    // 设置重复日期  
    if (options.daysOfWeek && options.daysOfWeek.length > 0) {  
      requestObj['daysOfWeek'] = options.daysOfWeek  
    }  

    // 发布提醒  
    reminderAgentManager.publishReminder(  
      requestObj as unknown as reminderAgentManager.ReminderRequest  
    )  
      .then((reminderId: number) => {  
        options?.success?.({  
          errMsg: 'publishAlarmReminder:ok',  
          reminderId: reminderId  
        })  
        options?.complete?.({ errMsg: 'publishAlarmReminder:ok' })  
      })  
      .catch((err: BusinessError) => {  
        options?.fail?.({ errMsg: `publishAlarmReminder:fail ${err.message}` })  
        options?.complete?.({ errMsg: `publishAlarmReminder:fail ${err.message}` })  
      })  
  } catch (err) {  
    const error = err as BusinessError  
    options?.fail?.({ errMsg: `publishAlarmReminder:fail ${error.message}` })  
    options?.complete?.({ errMsg: `publishAlarmReminder:fail ${error.message}` })  
  }  
}

实现要点:

  1. reminderType: 指定提醒类型为闹钟
  2. wantAgent: 配置点击提醒后要启动的应用和页面
  3. actionButton: 添加操作按钮
  4. daysOfWeek: 可选的重复日期数组
  5. 错误处理: 使用try-catch和Promise.catch双重错误处理

2.5.3 实现日历提醒

export function publishCalendarReminder(options: PublishCalendarReminderOptions): void {  
  try {  
    const date = new Date(options.dateTime)  

    const requestObj = {  
      reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR,  
      dateTime: {  
        year: date.getFullYear(),  
        month: date.getMonth() + 1,  
        day: date.getDate(),  
        hour: date.getHours(),  
        minute: date.getMinutes()  
      },  
      title: options.title || '日历提醒',  
      content: options.content || '事件提醒',  
      wantAgent: {  
        pkgName: 'com.liudd1.anshichiyao',  
        abilityName: 'EntryAbility'  
      },  
      actionButton: [{  
        title: '关闭',  
        type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_CLOSE  
      }]  
    } as UTSJSONObject  

    // 设置重复月份和日期  
    if (options.repeatMonths && options.repeatMonths.length > 0) {  
      requestObj['repeatMonths'] = options.repeatMonths  
    }  
    if (options.repeatDays && options.repeatDays.length > 0) {  
      requestObj['repeatDays'] = options.repeatDays  
    }  

    // 发布提醒(Promise处理逻辑同闹钟提醒)  
    // ...  
  } catch (err) {  
    // 错误处理  
  }  
}

2.5.4 实现倒计时提醒

export function publishTimerReminder(options: PublishTimerReminderOptions): void {  
  try {  
    const requestObj = {  
      reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_TIMER,  
      triggerTimeInSeconds: options.triggerTimeInSeconds,  
      title: options.title || '倒计时提醒',  
      content: options.content || '倒计时已到',  
      wantAgent: {  
        pkgName: 'com.liudd1.anshichiyao',  
        abilityName: 'EntryAbility'  
      },  
      actionButton: [{  
        title: '关闭',  
        type: reminderAgentManager.ActionButtonType.ACTION_BUTTON_TYPE_CLOSE  
      }]  
    } as UTSJSONObject  

    // 发布提醒  
    // ...  
  } catch (err) {  
    // 错误处理  
  }  
}

2.6 权限配置

使用代理提醒功能需要在manifest.json中声明权限:

{  
  "app-harmony": {  
    "permissions": [  
      "ohos.permission.PUBLISH_AGENT_REMINDER"  
    ]  
  }  
}

同时配合通知权限插件请求用户授权:

uni.requestNotification({  
  success: (res) => {  
    if (res.granted) {  
      console.log('通知权限已获取')  
    }  
  }  
})

2.7 在应用中使用插件

插件开发完成后,在应用中的使用非常简单:

export default {  
  data() {  
    return {  
      reminderIds: []  
    }  
  },  
  methods: {  
    // 设置每天早上8点的服药提醒  
    setMedicineReminder() {  
      uni.publishAlarmReminder({  
        hour: 8,  
        minute: 0,  
        daysOfWeek: [1, 2, 3, 4, 5, 6, 0],  
        title: '服药提醒',  
        content: '该吃药了,记得按时服药哦!',  
        success: (res) => {  
          console.log('提醒设置成功,ID:', res.reminderId)  
          this.reminderIds.push(res.reminderId)  
          uni.showToast({ title: '提醒设置成功' })  
        },  
        fail: (err) => {  
          console.error('提醒设置失败:', err.errMsg)  
        }  
      })  
    },  

    // 设置30分钟后的倒计时提醒  
    setTimerReminder() {  
      uni.publishTimerReminder({  
        triggerTimeInSeconds: 30 * 60,  
        title: '服药倒计时',  
        content: '30分钟已到,该服药了',  
        success: (res) => {  
          this.reminderIds.push(res.reminderId)  
        }  
      })  
    },  

    // 取消所有提醒  
    cancelAllReminders() {  
      uni.cancelAllReminders({  
        success: () => {  
          this.reminderIds = []  
          uni.showToast({ title: '已清空所有提醒' })  
        }  
      })  
    }  
  }  
}

希望本文的分享能够帮助到正在开发鸿蒙应用的开发者们,让我们一起为鸿蒙生态的繁荣贡献力量!

收起阅读 »

解决uniapp打包h5刷新页面无法返回上一级页面的问题

h5环境直接拦截默认的返回方法,使用router自带的返回

拦截默认的pageHead的返回方法,判断整个页面的history

import Vue from 'vue'  
if (process.env.VUE_APP_PLATFORM === 'h5') {  
  // 替代默认的返回api  
  uni.navigateBack = function (params: any) {  
    let canBack = true  
    const pages = getCurrentPages()  
    let delta = params?.delta  
    if (typeof delta !== 'number') delta = 1  

    const router = getApp().$router  
    if (pages.length) {  
      const from = 'navigateBack'  
      function hasLifecycleHook(options: any, hook: string) {  
        return Array.isArray(options[hook]) && options[hook].length  
      }  
      const page = pages[pages.length - 1] as any  
      if (  
        hasLifecycleHook(page.$options, 'onBackPress') &&  
        page.__call_hook('onBackPress', {  
          from,  
        }) === true  
      ) {  
        canBack = false  
      }  
    }  
    if (canBack) {  
      if (delta > 1) {  
        router._$delta = delta  
      }  
      router.go(-delta, {  
        animationType: '',  
        animationDuration: '',  
      })  
    }  
  }  
  // 修复 自带的pageHead 刷新点击右上角回到首页  
  const Page = Vue.component('Page')  
  const PageHead = Page.component('PageHead')  
  // @ts-ignore  
  PageHead.methods._back = function () {  
    if (history.length === 1) {  
      return uni.reLaunch({  
        url: '/',  
      })  
    } else {  
      return uni.navigateBack({  
        // @ts-ignore  
        from: 'backbutton',  
      })  
    }  
  }  
}  
继续阅读 »

h5环境直接拦截默认的返回方法,使用router自带的返回

拦截默认的pageHead的返回方法,判断整个页面的history

import Vue from 'vue'  
if (process.env.VUE_APP_PLATFORM === 'h5') {  
  // 替代默认的返回api  
  uni.navigateBack = function (params: any) {  
    let canBack = true  
    const pages = getCurrentPages()  
    let delta = params?.delta  
    if (typeof delta !== 'number') delta = 1  

    const router = getApp().$router  
    if (pages.length) {  
      const from = 'navigateBack'  
      function hasLifecycleHook(options: any, hook: string) {  
        return Array.isArray(options[hook]) && options[hook].length  
      }  
      const page = pages[pages.length - 1] as any  
      if (  
        hasLifecycleHook(page.$options, 'onBackPress') &&  
        page.__call_hook('onBackPress', {  
          from,  
        }) === true  
      ) {  
        canBack = false  
      }  
    }  
    if (canBack) {  
      if (delta > 1) {  
        router._$delta = delta  
      }  
      router.go(-delta, {  
        animationType: '',  
        animationDuration: '',  
      })  
    }  
  }  
  // 修复 自带的pageHead 刷新点击右上角回到首页  
  const Page = Vue.component('Page')  
  const PageHead = Page.component('PageHead')  
  // @ts-ignore  
  PageHead.methods._back = function () {  
    if (history.length === 1) {  
      return uni.reLaunch({  
        url: '/',  
      })  
    } else {  
      return uni.navigateBack({  
        // @ts-ignore  
        from: 'backbutton',  
      })  
    }  
  }  
}  
收起阅读 »

分片上传、文件分片,使用Html5Plus API。测试对比MD5。

兄弟们,我在看掘金的时候找到分片上传失败的原因了,就很小的一个点:

html5Plus 的文档写的不太清楚出了,很多地方让人猜。就比如File.slice(start,end) ,在js中slice截取不包含end,但在Html5Plus File.slice包含end,导致合并文件后总是比原文件大一点

也就是使用Html5Plus的File.slice方法:每个分片end-1
已经在安卓模拟器上测试,并对比MD5值。

因为没有 readAsArrayBuffer 方法,只能通过 Base64 转 ArrayBuffer,需要注意去除dataUrl头部
关键代码

const readFileChunk = (file, start, end) => {  
    return new Promise((resolve, reject) => {  
        try {  
            const reader = new plus.io.FileReader();  
            reader.onload = () => {  
                try {  
                    // 排除base64头部  
                    const base64 = reader.result.substring(reader.result.indexOf(',')   1);  
                    resolve(uni.base64ToArrayBuffer(base64));  
                } catch (e) {  
                    reject(e)  
                }  
            };  
            reader.onerror = reject;  

            // 使用slice方法读取指定范围, 需要注意: end 包含  
            const blob = file.slice(start, end);  
            reader.readAsDataURL(blob); // HTML5 需要转换为base64读取  
        } catch (e) {  
            reject(e)  
        }  
    });  
};

完整的分片上传

    async uploadMultipart(filePath, chunkSize = 1024 * 1024 * 5) {  

        // 通过文件路径获取File对象(Html5Plus的File)  
        const file = await getFile(filePath);  
        const uploadId = await multipartUploadApi.startMultipart(file.name)  
        // console.log('uploadId', uploadId)  

        const partList = []  
        const totalSize = file.size;  
        for (let i = 0; i < totalSize / chunkSize; i  ) {  
            const start = i * chunkSize;  
            const end = Math.min((i   1) * chunkSize - 1, totalSize);  
            const chunkBuffer = await readFileChunk(file, start, end);  
            const partItem = await multipartUploadApi.uploadPart(uploadId, i   1, chunkBuffer)  
            partList.push(partItem)  
        }  
        const result = await multipartUploadApi.complete(uploadId, partList)  

        return result  
    },  

    getFile(filePath) {  
        return new Promise((resolve, reject) => {  
            plus.io.resolveLocalFileSystemURL(filePath, (entry) => {  
                if (!entry.isFile) {  
                    reject(new Error(`不是文件:${filePath}`))  
                    return  
                }  
                entry.file((file) => {  
                    resolve(file)  
                }, (err) => {  
                    // console.log('无法获取文件')  
                    reject(err)  
                })  
            }, (error) => {  
                // console.log('文件不存在', error)  
                reject(error)  
            })  
        })  
    },  
  readFileChunk (file, start, end) {  
    return new Promise((resolve, reject) => {  
        try {  
            const reader = new plus.io.FileReader();  
            reader.onload = () => {  
                try {  
                    // 排除base64头部  
                    const base64 = reader.result.substring(reader.result.indexOf(',')   1);  
                    resolve(uni.base64ToArrayBuffer(base64));  
                } catch (e) {  
                    reject(e)  
                }  
            };  
            reader.onerror = reject;  

            // 使用slice方法读取指定范围, 需要注意: end 包含  
            const blob = file.slice(start, end);  
            reader.readAsDataURL(blob); // HTML5 需要转换为base64读取  
        } catch (e) {  
            reject(e)  
        }  
    });  
};

参考的掘金文章:
解决Uniapp中文件切片问题
作者:Synmbrf
链接:https://juejin.cn/post/7493783786707058726
来源:稀土掘金

继续阅读 »

兄弟们,我在看掘金的时候找到分片上传失败的原因了,就很小的一个点:

html5Plus 的文档写的不太清楚出了,很多地方让人猜。就比如File.slice(start,end) ,在js中slice截取不包含end,但在Html5Plus File.slice包含end,导致合并文件后总是比原文件大一点

也就是使用Html5Plus的File.slice方法:每个分片end-1
已经在安卓模拟器上测试,并对比MD5值。

因为没有 readAsArrayBuffer 方法,只能通过 Base64 转 ArrayBuffer,需要注意去除dataUrl头部
关键代码

const readFileChunk = (file, start, end) => {  
    return new Promise((resolve, reject) => {  
        try {  
            const reader = new plus.io.FileReader();  
            reader.onload = () => {  
                try {  
                    // 排除base64头部  
                    const base64 = reader.result.substring(reader.result.indexOf(',')   1);  
                    resolve(uni.base64ToArrayBuffer(base64));  
                } catch (e) {  
                    reject(e)  
                }  
            };  
            reader.onerror = reject;  

            // 使用slice方法读取指定范围, 需要注意: end 包含  
            const blob = file.slice(start, end);  
            reader.readAsDataURL(blob); // HTML5 需要转换为base64读取  
        } catch (e) {  
            reject(e)  
        }  
    });  
};

完整的分片上传

    async uploadMultipart(filePath, chunkSize = 1024 * 1024 * 5) {  

        // 通过文件路径获取File对象(Html5Plus的File)  
        const file = await getFile(filePath);  
        const uploadId = await multipartUploadApi.startMultipart(file.name)  
        // console.log('uploadId', uploadId)  

        const partList = []  
        const totalSize = file.size;  
        for (let i = 0; i < totalSize / chunkSize; i  ) {  
            const start = i * chunkSize;  
            const end = Math.min((i   1) * chunkSize - 1, totalSize);  
            const chunkBuffer = await readFileChunk(file, start, end);  
            const partItem = await multipartUploadApi.uploadPart(uploadId, i   1, chunkBuffer)  
            partList.push(partItem)  
        }  
        const result = await multipartUploadApi.complete(uploadId, partList)  

        return result  
    },  

    getFile(filePath) {  
        return new Promise((resolve, reject) => {  
            plus.io.resolveLocalFileSystemURL(filePath, (entry) => {  
                if (!entry.isFile) {  
                    reject(new Error(`不是文件:${filePath}`))  
                    return  
                }  
                entry.file((file) => {  
                    resolve(file)  
                }, (err) => {  
                    // console.log('无法获取文件')  
                    reject(err)  
                })  
            }, (error) => {  
                // console.log('文件不存在', error)  
                reject(error)  
            })  
        })  
    },  
  readFileChunk (file, start, end) {  
    return new Promise((resolve, reject) => {  
        try {  
            const reader = new plus.io.FileReader();  
            reader.onload = () => {  
                try {  
                    // 排除base64头部  
                    const base64 = reader.result.substring(reader.result.indexOf(',')   1);  
                    resolve(uni.base64ToArrayBuffer(base64));  
                } catch (e) {  
                    reject(e)  
                }  
            };  
            reader.onerror = reject;  

            // 使用slice方法读取指定范围, 需要注意: end 包含  
            const blob = file.slice(start, end);  
            reader.readAsDataURL(blob); // HTML5 需要转换为base64读取  
        } catch (e) {  
            reject(e)  
        }  
    });  
};

参考的掘金文章:
解决Uniapp中文件切片问题
作者:Synmbrf
链接:https://juejin.cn/post/7493783786707058726
来源:稀土掘金

收起阅读 »

基于vue3.5+vite7.1+tauri2.8实战客户端聊天软件

vite vue.js vue3

vue3-tauri2-wechat:最新研发vite7.1+tauri2.8+vue3 setup+pinia3+elementPlus跨平台仿微信/QQ风格桌面聊天系统Exe模板。包含聊天、通讯录、收藏、朋友圈、短视频、我的等板块。

运用技术

  • 跨平台框架:tauri2.8
  • 前端技术框架:vite^7.1.10+vue^3.5.22+vue-router^4.6.3
  • 状态管理:pinia^3.0.3
  • 本地存储:pinia-plugin-persistedstate^4.5.0
  • 组件库:element-plus^2.11.5
  • 富文本编辑器:@vueup/vue-quill^1.2.0
  • 样式预处理:sass^1.93.2
  • 短视频滑动插件:swiper^12.0.2

项目框架目录

基于最新版跨平台框架tauri2+vite7创建项目模板,vue3 setup语法开发。

tauri2-vue3chat聊天系统已经更新到我的原创作品集。
Tauri2.0+Vite7+ElementPlus桌面聊天Exe程序

往期推荐

Electron38-Vue3OS客户端OS系统|vite7+electron38+arco桌面os后台管理
electron38-admin桌面端后台|Electron38+Vue3+ElementPlus管理系统
Electron38-Wechat电脑端聊天|vite7+electron38仿微信桌面端聊天系统
原创uniapp+vue3+deepseek+uv-ui跨端实战仿deepseek/豆包流式ai聊天对话助手。
vue3-webseek网页版AI问答|Vite6+DeepSeek+Arco流式ai聊天打字效果
最新版uni-app+vue3+uv-ui跨三端仿微信app聊天应用【h5+小程序+app端】
Flutter3-MacOS桌面OS系统|flutter3.32+window_manager客户端OS模板
最新研发flutter3.27+bitsdojo_window+getx客户端仿微信聊天Exe应用
最新版Flutter3.32+Dart3.8跨平台仿微信app聊天界面|朋友圈
最新版uniapp+vue3+uv-ui跨三端短视频+直播+聊天【H5+小程序+App端】
uniapp-vue3-os手机oa系统|uni-app+vue3跨三端os后台管理模板
Electron35-DeepSeek桌面端AI系统|vue3.5+electron+arco客户端ai模板
uniapp+vue3酒店预订|vite5+uniapp预约订房系统模板(h5+小程序+App端)
Tauri2.0+Vite5聊天室|vue3+tauri2+element-plus仿微信|tauri聊天应用
tauri2.0-admin桌面端后台系统|Tauri2+Vite5+ElementPlus管理后台EXE程序

继续阅读 »

vue3-tauri2-wechat:最新研发vite7.1+tauri2.8+vue3 setup+pinia3+elementPlus跨平台仿微信/QQ风格桌面聊天系统Exe模板。包含聊天、通讯录、收藏、朋友圈、短视频、我的等板块。

运用技术

  • 跨平台框架:tauri2.8
  • 前端技术框架:vite^7.1.10+vue^3.5.22+vue-router^4.6.3
  • 状态管理:pinia^3.0.3
  • 本地存储:pinia-plugin-persistedstate^4.5.0
  • 组件库:element-plus^2.11.5
  • 富文本编辑器:@vueup/vue-quill^1.2.0
  • 样式预处理:sass^1.93.2
  • 短视频滑动插件:swiper^12.0.2

项目框架目录

基于最新版跨平台框架tauri2+vite7创建项目模板,vue3 setup语法开发。

tauri2-vue3chat聊天系统已经更新到我的原创作品集。
Tauri2.0+Vite7+ElementPlus桌面聊天Exe程序

往期推荐

Electron38-Vue3OS客户端OS系统|vite7+electron38+arco桌面os后台管理
electron38-admin桌面端后台|Electron38+Vue3+ElementPlus管理系统
Electron38-Wechat电脑端聊天|vite7+electron38仿微信桌面端聊天系统
原创uniapp+vue3+deepseek+uv-ui跨端实战仿deepseek/豆包流式ai聊天对话助手。
vue3-webseek网页版AI问答|Vite6+DeepSeek+Arco流式ai聊天打字效果
最新版uni-app+vue3+uv-ui跨三端仿微信app聊天应用【h5+小程序+app端】
Flutter3-MacOS桌面OS系统|flutter3.32+window_manager客户端OS模板
最新研发flutter3.27+bitsdojo_window+getx客户端仿微信聊天Exe应用
最新版Flutter3.32+Dart3.8跨平台仿微信app聊天界面|朋友圈
最新版uniapp+vue3+uv-ui跨三端短视频+直播+聊天【H5+小程序+App端】
uniapp-vue3-os手机oa系统|uni-app+vue3跨三端os后台管理模板
Electron35-DeepSeek桌面端AI系统|vue3.5+electron+arco客户端ai模板
uniapp+vue3酒店预订|vite5+uniapp预约订房系统模板(h5+小程序+App端)
Tauri2.0+Vite5聊天室|vue3+tauri2+element-plus仿微信|tauri聊天应用
tauri2.0-admin桌面端后台系统|Tauri2+Vite5+ElementPlus管理后台EXE程序

收起阅读 »

使用 uni-app x 开发2048游戏适配鸿蒙6

鸿蒙征文

使用 uni-app x 开发2048游戏适配鸿蒙6

作者:坚果派小雨
发布时间:2025年10月
技术栈:uni-app x、UTS、HarmonyOS 6

📖 前言

2048是一款风靡全球的益智游戏,简单而富有策略性。本文将详细介绍如何使用 uni-app x 框架从零开始开发一款2048游戏,并实现深色模式适配、数据持久化等进阶功能。最终产品完美支持鸿蒙HarmonyOS 6、Android、iOS等多个平台。

为什么选择 uni-app x?

  • 🚀 原生性能:UTS 语言编译为原生代码,性能接近原生应用
  • 📱 一次开发,多端运行:支持鸿蒙、Android、iOS、Web等平台
  • 💪 类型安全:基于 TypeScript,享受完整的类型检查
  • 🎯 鸿蒙首选:官方支持鸿蒙6,是开发鸿蒙应用的最佳选择之一

🎯 项目目标

我们将实现以下功能:

  1. ✅ 完整的2048游戏逻辑
  2. ✅ 流畅的触摸手势控制
  3. ✅ 精美的动画效果
  4. ✅ 深色模式自动适配
  5. ✅ 最高分本地存储
  6. ✅ 多平台支持(重点支持鸿蒙6)

📐 架构设计

数据结构设计

游戏的核心是一个 4x4 的二维数组,用于存储每个格子的数值:

// 游戏网格数据  
grid: number[][] = [  
  [0, 0, 0, 0],  
  [0, 0, 0, 0],  
  [0, 0, 0, 0],  
  [0, 0, 0, 0]  
]

为了实现平滑的动画效果,我们需要一个独立的 Tile 数据结构:

type Tile = {  
  id: number          // 唯一标识  
  value: number       // 方块数值  
  row: number         // 行位置  
  col: number         // 列位置  
  isNew: boolean      // 是否是新生成的  
  isMerged: boolean   // 是否刚合并  
}

状态管理

使用 Vue 3 的响应式系统管理游戏状态:

data() {  
  return {  
    grid: [] as number[][],        // 游戏网格  
    tiles: [] as Tile[],           // 显示的方块  
    score: 0,                      // 当前分数  
    bestScore: 0,                  // 最高分  
    gameOver: false,               // 游戏结束  
    gameWon: false,                // 游戏胜利  
    keepPlaying: false,            // 继续游戏  
    tileIdCounter: 0,              // 方块ID计数器  
    isDarkMode: false              // 深色模式  
  }  
}

🔧 核心功能实现

1. 游戏初始化

游戏开始时需要初始化网格并随机生成两个方块:

initGame() {  
  // 初始化4x4网格  
  this.grid = []  
  for (let i = 0; i < 4; i++) {  
    this.grid.push([0, 0, 0, 0])  
  }  

  // 重置状态  
  this.tiles = []  
  this.score = 0  
  this.gameOver = false  
  this.gameWon = false  
  this.tileIdCounter = 0  

  // 添加两个初始方块  
  this.addRandomTile()  
  this.addRandomTile()  
}

2. 随机生成方块

90%概率生成2,10%概率生成4,这是经典2048的设定:

addRandomTile() {  
  // 找出所有空格子  
  const emptyCells = [] as {row: number, col: number}[]  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      if (this.grid[i][j] === 0) {  
        emptyCells.push({row: i, col: j})  
      }  
    }  
  }  

  if (emptyCells.length > 0) {  
    // 随机选择一个空格子  
    const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)]  

    // 90%概率生成2,10%概率生成4  
    const value = Math.random() < 0.9 ? 2 : 4  
    this.grid[randomCell.row][randomCell.col] = value  

    // 创建新的方块对象  
    const tile: Tile = {  
      id: this.tileIdCounter++,  
      value: value,  
      row: randomCell.row,  
      col: randomCell.col,  
      isNew: true,  
      isMerged: false  
    }  
    this.tiles.push(tile)  

    // 200ms后移除新方块标记,触发动画  
    setTimeout(() => {  
      tile.isNew = false  
    }, 200)  
  }  
}

3. 触摸手势处理

实现流畅的滑动手势检测:

// 记录触摸起点  
touchStart(e: UniTouchEvent) {  
  this.touchStartX = e.touches[0].pageX  
  this.touchStartY = e.touches[0].pageY  
}  

// 计算滑动方向  
touchEnd(e: UniTouchEvent) {  
  this.touchEndX = e.changedTouches[0].pageX  
  this.touchEndY = e.changedTouches[0].pageY  
  this.handleSwipe()  
}  

// 判断滑动方向  
handleSwipe() {  
  const deltaX = this.touchEndX - this.touchStartX  
  const deltaY = this.touchEndY - this.touchStartY  
  const minSwipeDistance = 30  // 最小滑动距离  

  // 滑动距离太短,忽略  
  if (Math.abs(deltaX) < minSwipeDistance &&   
      Math.abs(deltaY) < minSwipeDistance) {  
    return  
  }  

  // 比较水平和垂直位移,判断主要方向  
  if (Math.abs(deltaX) > Math.abs(deltaY)) {  
    // 水平滑动  
    deltaX > 0 ? this.move('right') : this.move('left')  
  } else {  
    // 垂直滑动  
    deltaY > 0 ? this.move('down') : this.move('up')  
  }  
}

4. 移动和合并算法

这是游戏的核心逻辑。以向左移动为例:

moveLeft(): boolean {  
  let moved = false  

  for (let i = 0; i < 4; i++) {  
    // 记录每个位置是否已经合并过  
    let merged = [false, false, false, false]  

    // 从左到右遍历每一行  
    for (let j = 1; j < 4; j++) {  
      if (this.grid[i][j] !== 0) {  
        let col = j  

        // 尽可能向左移动  
        while (col > 0) {  
          // 左边是空格,移动过去  
          if (this.grid[i][col - 1] === 0) {  
            this.grid[i][col - 1] = this.grid[i][col]  
            this.grid[i][col] = 0  
            col--  
            moved = true  
          }   
          // 左边数字相同且未合并,合并  
          else if (this.grid[i][col - 1] === this.grid[i][col] &&   
                   !merged[col - 1]) {  
            this.grid[i][col - 1] *= 2  
            this.score += this.grid[i][col - 1]  
            this.grid[i][col] = 0  
            merged[col - 1] = true  
            moved = true  
            break  
          }   
          // 无法移动  
          else {  
            break  
          }  
        }  
      }  
    }  
  }  

  return moved  
}

算法关键点:

  • 使用 merged 数组防止一次移动中多次合并
  • 从移动方向的对侧开始遍历
  • 每个方块尽可能移动到最远位置

5. 游戏状态检测

胜利检测

hasWon(): boolean {  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      if (this.grid[i][j] === 2048) {  
        return true  
      }  
    }  
  }  
  return false  
}

游戏结束检测

isGameOver(): boolean {  
  // 1. 检查是否有空格  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      if (this.grid[i][j] === 0) {  
        return false  
      }  
    }  
  }  

  // 2. 检查是否可以合并  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      const current = this.grid[i][j]  
      // 检查右边  
      if (j < 3 && current === this.grid[i][j + 1]) {  
        return false  
      }  
      // 检查下边  
      if (i < 3 && current === this.grid[i + 1][j]) {  
        return false  
      }  
    }  
  }  

  return true  
}

🎨 界面设计与动画

1. 网格布局

使用 CSS 定位实现精确的网格布局:

.game-container {  
  position: relative;  
  width: 670rpx;  
  height: 670rpx;  
  background-color: #bbada0;  
  border-radius: 12rpx;  
  padding: 20rpx;  
}  

.grid-cell {  
  width: 142.5rpx;  
  height: 142.5rpx;  
  background-color: rgba(238, 228, 218, 0.35);  
  border-radius: 6rpx;  
}

计算公式:

单元格宽度 = (容器宽度 - 内边距*2 - 间距*3) / 4  
142.5rpx = (670 - 20*2 - 20*3) / 4

2. 方块定位

使用动态类名实现方块的精确定位:

<view   
  :class="[  
    'tile',   
    '' + tile.value,  
    'tile-' + tile.row + '-' + tile.col  
  ]"  
>  
  <text class="tile-inner">{{tile.value}}</text>  
</view>

CSS 定位规则:

.tile-position-0-0 { top: 0rpx; left: 0rpx; }  
.tile-position-0-1 { top: 0rpx; left: 162.5rpx; }  
/* ... 16个位置 ... */

3. 出现动画

新方块从小到大弹出:

.tile-new {  
  animation: appear 0.2s ease-in-out;  
}  

@keyframes appear {  
  0% {  
    opacity: 0;  
    transform: scale(0);  
  }  
  100% {  
    opacity: 1;  
    transform: scale(1);  
  }  
}

4. 合并动画

合并时放大再缩小:

.tile-merged {  
  animation: pop 0.2s ease-in-out;  
}  

@keyframes pop {  
  0% { transform: scale(1); }  
  50% { transform: scale(1.2); }  
  100% { transform: scale(1); }  
}

5. 渐变配色

不同数值的方块使用不同颜色:

.tile-2 { background-color: #eee4da; color: #776e65; }  
.tile-4 { background-color: #ede0c8; color: #776e65; }  
.tile-8 { background-color: #f2b179; color: #f9f6f2; }  
.tile-16 { background-color: #f59563; color: #f9f6f2; }  
/* ... */  
.tile-2048 { background-color: #edc22e; color: #f9f6f2; }

🌓 深色模式适配

1. 检测系统主题

detectTheme() {  
  const systemInfo = uni.getSystemInfoSync()  
  // @ts-ignore  
  this.isDarkMode = systemInfo.theme === 'dark' ||   
                    systemInfo.osTheme === 'dark'  
}  

onShow() {  
  // 每次显示时检测主题变化  
  this.detectTheme()  
}

2. 深色模式样式

使用级联选择器为深色模式定制样式:

/* 深色模式容器 */  
.dark-mode {  
  background-color: #1a1a1a;  
}  

/* 深色模式下的游戏网格 */  
.dark-mode .game-container {  
  background-color: #4a4340;  
}  

/* 深色模式下的方块 */  
.dark-mode .tile-2 {   
  background-color: #3a3a3a;  
  color: #c9c9c9;  
}  

.dark-mode .tile-4 {   
  background-color: #4a4a4a;  
  color: #d4d4d4;  
}

设计原则:

  • 降低对比度,减少眼睛疲劳
  • 保持色彩层次感
  • 确保文字清晰可读

💾 数据持久化

使用 uni-app 的本地存储 API 保存最高分:

// 保存最高分  
saveBestScore() {  
  uni.setStorageSync('bestScore', this.bestScore)  
}  

// 加载最高分  
loadBestScore() {  
  const saved = uni.getStorageSync('bestScore')  
  if (saved !== null && saved !== undefined && saved !== '') {  
    this.bestScore = parseInt(saved as string)  
  }  
}  

// 在分数更新时检查  
if (this.score > this.bestScore) {  
  this.bestScore = this.score  
  this.saveBestScore()  
}

📱 多平台适配

鸿蒙6特别优化

  1. 返回键处理(App.uvue):
onLastPageBackPress: function () {  
  if (firstBackTime == 0) {  
    uni.showToast({  
      title: '再按一次退出应用',  
      position: 'bottom',  
    })  
    firstBackTime = Date.now()  
    setTimeout(() => {  
      firstBackTime = 0  
    }, 2000)  
  } else if (Date.now() - firstBackTime < 2000) {  
    uni.exit()  
  }  
}
  1. rpx单位适配

    • rpx 是 uni-app 的响应式单位
    • 750rpx = 屏幕宽度
    • 自动适配不同屏幕尺寸
  2. 触摸事件优化

    • 使用原生触摸事件
    • 最小滑动距离30px
    • 防止误触

🐛 常见问题与解决方案

问题1:方块移动后位置不更新

原因:直接修改数组元素不会触发视图更新

解决方案:使用 updateTiles() 方法重建 tiles 数组

updateTiles() {  
  const newTiles = [] as Tile[]  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      if (this.grid[i][j] !== 0) {  
        let existingTile = this.tiles.find(t =>   
          t.row === i && t.col === j && !t.isNew  
        )  
        if (existingTile) {  
          existingTile.row = i  
          existingTile.col = j  
          existingTile.value = this.grid[i][j]  
          newTiles.push(existingTile)  
        }  
      }  
    }  
  }  
  this.tiles = newTiles  
}

问题2:一次移动合并多次

原因:没有标记已合并的位置

解决方案:使用 merged 数组记录

let merged = [false, false, false, false]  
// 合并时检查  
if (!merged[col - 1]) {  
  // 执行合并  
  merged[col - 1] = true  
}

问题3:动画不流畅

原因:方块 ID 重复或变化

解决方案:使用全局计数器生成唯一 ID

tileIdCounter: 0  

// 创建新方块时  
tile.id = this.tileIdCounter++

📊 性能优化

1. 减少不必要的渲染

// 只在有移动时才更新  
if (moved) {  
  this.addRandomTile()  
  this.updateTiles()  
}

2. 使用 CSS 动画而非 JS

.tile {  
  transition-property: transform;  
  transition-duration: 0.1s;  
  transition-timing-function: ease-in-out;  
}

3. 合理使用 v-if 和 v-show

<!-- 游戏结束遮罩使用 v-if -->  
<view class="game-message" v-if="gameOver || gameWon">  
  <!-- ... -->  
</view>

🚀 打包发布

鸿蒙应用打包

  1. 配置 manifest.json
  2. 在 HBuilderX 中选择"发行" -> "原生App-云打包"
  3. 选择鸿蒙平台
  4. 配置签名证书
  5. 打包上传

注意事项

  • 准备应用图标(512x512px)
  • 准备启动页图片
  • 填写应用描述和权限说明
  • 测试多种屏幕尺寸

📈 后续优化方向

功能扩展

  1. 撤销功能:保存每一步的状态
  2. 自定义尺寸:支持 3x3、5x5 网格
  3. 主题切换:多种配色方案
  4. 音效反馈:移动、合并音效
  5. 震动反馈:使用 uni.vibrateShort()

社交功能

  1. 排行榜:云端存储最高分
  2. 分享功能:分享到社交平台
  3. 成就系统:解锁各种成就
  4. 每日挑战:特殊模式挑战

体验优化

  1. 引导动画:首次进入时的教程
  2. 手势提示:显示滑动方向
  3. 历史记录:查看历史最高分
  4. 统计数据:游戏次数、时长等

💡 总结

通过这个项目,我们学到了:

  1. uni-app x 的基本用法:页面结构、数据绑定、事件处理
  2. UTS 语言特性:类型定义、类型安全
  3. 游戏算法实现:移动、合并、状态检测
  4. CSS 动画技巧:关键帧动画、过渡效果
  5. 响应式设计:rpx 单位、多屏适配
  6. 鸿蒙应用开发:平台特性、适配要点

uni-app x 是一个强大的跨平台开发框架,特别适合开发鸿蒙应用。通过本项目的实践,希望能帮助你快速上手 uni-app x 开发,创造出更多优秀的应用!

📚 参考资源


作者:坚果派小雨
项目地址: GitCode
开源协议: MIT License

如果觉得本文对你有帮助,欢迎点赞、收藏、分享!有任何问题也欢迎在评论区讨论。


💡 提示:本文涉及的完整代码已开源,你可以直接下载运行,也可以在此基础上进行二次开发。期待看到你的创意!

继续阅读 »

使用 uni-app x 开发2048游戏适配鸿蒙6

作者:坚果派小雨
发布时间:2025年10月
技术栈:uni-app x、UTS、HarmonyOS 6

📖 前言

2048是一款风靡全球的益智游戏,简单而富有策略性。本文将详细介绍如何使用 uni-app x 框架从零开始开发一款2048游戏,并实现深色模式适配、数据持久化等进阶功能。最终产品完美支持鸿蒙HarmonyOS 6、Android、iOS等多个平台。

为什么选择 uni-app x?

  • 🚀 原生性能:UTS 语言编译为原生代码,性能接近原生应用
  • 📱 一次开发,多端运行:支持鸿蒙、Android、iOS、Web等平台
  • 💪 类型安全:基于 TypeScript,享受完整的类型检查
  • 🎯 鸿蒙首选:官方支持鸿蒙6,是开发鸿蒙应用的最佳选择之一

🎯 项目目标

我们将实现以下功能:

  1. ✅ 完整的2048游戏逻辑
  2. ✅ 流畅的触摸手势控制
  3. ✅ 精美的动画效果
  4. ✅ 深色模式自动适配
  5. ✅ 最高分本地存储
  6. ✅ 多平台支持(重点支持鸿蒙6)

📐 架构设计

数据结构设计

游戏的核心是一个 4x4 的二维数组,用于存储每个格子的数值:

// 游戏网格数据  
grid: number[][] = [  
  [0, 0, 0, 0],  
  [0, 0, 0, 0],  
  [0, 0, 0, 0],  
  [0, 0, 0, 0]  
]

为了实现平滑的动画效果,我们需要一个独立的 Tile 数据结构:

type Tile = {  
  id: number          // 唯一标识  
  value: number       // 方块数值  
  row: number         // 行位置  
  col: number         // 列位置  
  isNew: boolean      // 是否是新生成的  
  isMerged: boolean   // 是否刚合并  
}

状态管理

使用 Vue 3 的响应式系统管理游戏状态:

data() {  
  return {  
    grid: [] as number[][],        // 游戏网格  
    tiles: [] as Tile[],           // 显示的方块  
    score: 0,                      // 当前分数  
    bestScore: 0,                  // 最高分  
    gameOver: false,               // 游戏结束  
    gameWon: false,                // 游戏胜利  
    keepPlaying: false,            // 继续游戏  
    tileIdCounter: 0,              // 方块ID计数器  
    isDarkMode: false              // 深色模式  
  }  
}

🔧 核心功能实现

1. 游戏初始化

游戏开始时需要初始化网格并随机生成两个方块:

initGame() {  
  // 初始化4x4网格  
  this.grid = []  
  for (let i = 0; i < 4; i++) {  
    this.grid.push([0, 0, 0, 0])  
  }  

  // 重置状态  
  this.tiles = []  
  this.score = 0  
  this.gameOver = false  
  this.gameWon = false  
  this.tileIdCounter = 0  

  // 添加两个初始方块  
  this.addRandomTile()  
  this.addRandomTile()  
}

2. 随机生成方块

90%概率生成2,10%概率生成4,这是经典2048的设定:

addRandomTile() {  
  // 找出所有空格子  
  const emptyCells = [] as {row: number, col: number}[]  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      if (this.grid[i][j] === 0) {  
        emptyCells.push({row: i, col: j})  
      }  
    }  
  }  

  if (emptyCells.length > 0) {  
    // 随机选择一个空格子  
    const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)]  

    // 90%概率生成2,10%概率生成4  
    const value = Math.random() < 0.9 ? 2 : 4  
    this.grid[randomCell.row][randomCell.col] = value  

    // 创建新的方块对象  
    const tile: Tile = {  
      id: this.tileIdCounter++,  
      value: value,  
      row: randomCell.row,  
      col: randomCell.col,  
      isNew: true,  
      isMerged: false  
    }  
    this.tiles.push(tile)  

    // 200ms后移除新方块标记,触发动画  
    setTimeout(() => {  
      tile.isNew = false  
    }, 200)  
  }  
}

3. 触摸手势处理

实现流畅的滑动手势检测:

// 记录触摸起点  
touchStart(e: UniTouchEvent) {  
  this.touchStartX = e.touches[0].pageX  
  this.touchStartY = e.touches[0].pageY  
}  

// 计算滑动方向  
touchEnd(e: UniTouchEvent) {  
  this.touchEndX = e.changedTouches[0].pageX  
  this.touchEndY = e.changedTouches[0].pageY  
  this.handleSwipe()  
}  

// 判断滑动方向  
handleSwipe() {  
  const deltaX = this.touchEndX - this.touchStartX  
  const deltaY = this.touchEndY - this.touchStartY  
  const minSwipeDistance = 30  // 最小滑动距离  

  // 滑动距离太短,忽略  
  if (Math.abs(deltaX) < minSwipeDistance &&   
      Math.abs(deltaY) < minSwipeDistance) {  
    return  
  }  

  // 比较水平和垂直位移,判断主要方向  
  if (Math.abs(deltaX) > Math.abs(deltaY)) {  
    // 水平滑动  
    deltaX > 0 ? this.move('right') : this.move('left')  
  } else {  
    // 垂直滑动  
    deltaY > 0 ? this.move('down') : this.move('up')  
  }  
}

4. 移动和合并算法

这是游戏的核心逻辑。以向左移动为例:

moveLeft(): boolean {  
  let moved = false  

  for (let i = 0; i < 4; i++) {  
    // 记录每个位置是否已经合并过  
    let merged = [false, false, false, false]  

    // 从左到右遍历每一行  
    for (let j = 1; j < 4; j++) {  
      if (this.grid[i][j] !== 0) {  
        let col = j  

        // 尽可能向左移动  
        while (col > 0) {  
          // 左边是空格,移动过去  
          if (this.grid[i][col - 1] === 0) {  
            this.grid[i][col - 1] = this.grid[i][col]  
            this.grid[i][col] = 0  
            col--  
            moved = true  
          }   
          // 左边数字相同且未合并,合并  
          else if (this.grid[i][col - 1] === this.grid[i][col] &&   
                   !merged[col - 1]) {  
            this.grid[i][col - 1] *= 2  
            this.score += this.grid[i][col - 1]  
            this.grid[i][col] = 0  
            merged[col - 1] = true  
            moved = true  
            break  
          }   
          // 无法移动  
          else {  
            break  
          }  
        }  
      }  
    }  
  }  

  return moved  
}

算法关键点:

  • 使用 merged 数组防止一次移动中多次合并
  • 从移动方向的对侧开始遍历
  • 每个方块尽可能移动到最远位置

5. 游戏状态检测

胜利检测

hasWon(): boolean {  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      if (this.grid[i][j] === 2048) {  
        return true  
      }  
    }  
  }  
  return false  
}

游戏结束检测

isGameOver(): boolean {  
  // 1. 检查是否有空格  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      if (this.grid[i][j] === 0) {  
        return false  
      }  
    }  
  }  

  // 2. 检查是否可以合并  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      const current = this.grid[i][j]  
      // 检查右边  
      if (j < 3 && current === this.grid[i][j + 1]) {  
        return false  
      }  
      // 检查下边  
      if (i < 3 && current === this.grid[i + 1][j]) {  
        return false  
      }  
    }  
  }  

  return true  
}

🎨 界面设计与动画

1. 网格布局

使用 CSS 定位实现精确的网格布局:

.game-container {  
  position: relative;  
  width: 670rpx;  
  height: 670rpx;  
  background-color: #bbada0;  
  border-radius: 12rpx;  
  padding: 20rpx;  
}  

.grid-cell {  
  width: 142.5rpx;  
  height: 142.5rpx;  
  background-color: rgba(238, 228, 218, 0.35);  
  border-radius: 6rpx;  
}

计算公式:

单元格宽度 = (容器宽度 - 内边距*2 - 间距*3) / 4  
142.5rpx = (670 - 20*2 - 20*3) / 4

2. 方块定位

使用动态类名实现方块的精确定位:

<view   
  :class="[  
    'tile',   
    '' + tile.value,  
    'tile-' + tile.row + '-' + tile.col  
  ]"  
>  
  <text class="tile-inner">{{tile.value}}</text>  
</view>

CSS 定位规则:

.tile-position-0-0 { top: 0rpx; left: 0rpx; }  
.tile-position-0-1 { top: 0rpx; left: 162.5rpx; }  
/* ... 16个位置 ... */

3. 出现动画

新方块从小到大弹出:

.tile-new {  
  animation: appear 0.2s ease-in-out;  
}  

@keyframes appear {  
  0% {  
    opacity: 0;  
    transform: scale(0);  
  }  
  100% {  
    opacity: 1;  
    transform: scale(1);  
  }  
}

4. 合并动画

合并时放大再缩小:

.tile-merged {  
  animation: pop 0.2s ease-in-out;  
}  

@keyframes pop {  
  0% { transform: scale(1); }  
  50% { transform: scale(1.2); }  
  100% { transform: scale(1); }  
}

5. 渐变配色

不同数值的方块使用不同颜色:

.tile-2 { background-color: #eee4da; color: #776e65; }  
.tile-4 { background-color: #ede0c8; color: #776e65; }  
.tile-8 { background-color: #f2b179; color: #f9f6f2; }  
.tile-16 { background-color: #f59563; color: #f9f6f2; }  
/* ... */  
.tile-2048 { background-color: #edc22e; color: #f9f6f2; }

🌓 深色模式适配

1. 检测系统主题

detectTheme() {  
  const systemInfo = uni.getSystemInfoSync()  
  // @ts-ignore  
  this.isDarkMode = systemInfo.theme === 'dark' ||   
                    systemInfo.osTheme === 'dark'  
}  

onShow() {  
  // 每次显示时检测主题变化  
  this.detectTheme()  
}

2. 深色模式样式

使用级联选择器为深色模式定制样式:

/* 深色模式容器 */  
.dark-mode {  
  background-color: #1a1a1a;  
}  

/* 深色模式下的游戏网格 */  
.dark-mode .game-container {  
  background-color: #4a4340;  
}  

/* 深色模式下的方块 */  
.dark-mode .tile-2 {   
  background-color: #3a3a3a;  
  color: #c9c9c9;  
}  

.dark-mode .tile-4 {   
  background-color: #4a4a4a;  
  color: #d4d4d4;  
}

设计原则:

  • 降低对比度,减少眼睛疲劳
  • 保持色彩层次感
  • 确保文字清晰可读

💾 数据持久化

使用 uni-app 的本地存储 API 保存最高分:

// 保存最高分  
saveBestScore() {  
  uni.setStorageSync('bestScore', this.bestScore)  
}  

// 加载最高分  
loadBestScore() {  
  const saved = uni.getStorageSync('bestScore')  
  if (saved !== null && saved !== undefined && saved !== '') {  
    this.bestScore = parseInt(saved as string)  
  }  
}  

// 在分数更新时检查  
if (this.score > this.bestScore) {  
  this.bestScore = this.score  
  this.saveBestScore()  
}

📱 多平台适配

鸿蒙6特别优化

  1. 返回键处理(App.uvue):
onLastPageBackPress: function () {  
  if (firstBackTime == 0) {  
    uni.showToast({  
      title: '再按一次退出应用',  
      position: 'bottom',  
    })  
    firstBackTime = Date.now()  
    setTimeout(() => {  
      firstBackTime = 0  
    }, 2000)  
  } else if (Date.now() - firstBackTime < 2000) {  
    uni.exit()  
  }  
}
  1. rpx单位适配

    • rpx 是 uni-app 的响应式单位
    • 750rpx = 屏幕宽度
    • 自动适配不同屏幕尺寸
  2. 触摸事件优化

    • 使用原生触摸事件
    • 最小滑动距离30px
    • 防止误触

🐛 常见问题与解决方案

问题1:方块移动后位置不更新

原因:直接修改数组元素不会触发视图更新

解决方案:使用 updateTiles() 方法重建 tiles 数组

updateTiles() {  
  const newTiles = [] as Tile[]  
  for (let i = 0; i < 4; i++) {  
    for (let j = 0; j < 4; j++) {  
      if (this.grid[i][j] !== 0) {  
        let existingTile = this.tiles.find(t =>   
          t.row === i && t.col === j && !t.isNew  
        )  
        if (existingTile) {  
          existingTile.row = i  
          existingTile.col = j  
          existingTile.value = this.grid[i][j]  
          newTiles.push(existingTile)  
        }  
      }  
    }  
  }  
  this.tiles = newTiles  
}

问题2:一次移动合并多次

原因:没有标记已合并的位置

解决方案:使用 merged 数组记录

let merged = [false, false, false, false]  
// 合并时检查  
if (!merged[col - 1]) {  
  // 执行合并  
  merged[col - 1] = true  
}

问题3:动画不流畅

原因:方块 ID 重复或变化

解决方案:使用全局计数器生成唯一 ID

tileIdCounter: 0  

// 创建新方块时  
tile.id = this.tileIdCounter++

📊 性能优化

1. 减少不必要的渲染

// 只在有移动时才更新  
if (moved) {  
  this.addRandomTile()  
  this.updateTiles()  
}

2. 使用 CSS 动画而非 JS

.tile {  
  transition-property: transform;  
  transition-duration: 0.1s;  
  transition-timing-function: ease-in-out;  
}

3. 合理使用 v-if 和 v-show

<!-- 游戏结束遮罩使用 v-if -->  
<view class="game-message" v-if="gameOver || gameWon">  
  <!-- ... -->  
</view>

🚀 打包发布

鸿蒙应用打包

  1. 配置 manifest.json
  2. 在 HBuilderX 中选择"发行" -> "原生App-云打包"
  3. 选择鸿蒙平台
  4. 配置签名证书
  5. 打包上传

注意事项

  • 准备应用图标(512x512px)
  • 准备启动页图片
  • 填写应用描述和权限说明
  • 测试多种屏幕尺寸

📈 后续优化方向

功能扩展

  1. 撤销功能:保存每一步的状态
  2. 自定义尺寸:支持 3x3、5x5 网格
  3. 主题切换:多种配色方案
  4. 音效反馈:移动、合并音效
  5. 震动反馈:使用 uni.vibrateShort()

社交功能

  1. 排行榜:云端存储最高分
  2. 分享功能:分享到社交平台
  3. 成就系统:解锁各种成就
  4. 每日挑战:特殊模式挑战

体验优化

  1. 引导动画:首次进入时的教程
  2. 手势提示:显示滑动方向
  3. 历史记录:查看历史最高分
  4. 统计数据:游戏次数、时长等

💡 总结

通过这个项目,我们学到了:

  1. uni-app x 的基本用法:页面结构、数据绑定、事件处理
  2. UTS 语言特性:类型定义、类型安全
  3. 游戏算法实现:移动、合并、状态检测
  4. CSS 动画技巧:关键帧动画、过渡效果
  5. 响应式设计:rpx 单位、多屏适配
  6. 鸿蒙应用开发:平台特性、适配要点

uni-app x 是一个强大的跨平台开发框架,特别适合开发鸿蒙应用。通过本项目的实践,希望能帮助你快速上手 uni-app x 开发,创造出更多优秀的应用!

📚 参考资源


作者:坚果派小雨
项目地址: GitCode
开源协议: MIT License

如果觉得本文对你有帮助,欢迎点赞、收藏、分享!有任何问题也欢迎在评论区讨论。


💡 提示:本文涉及的完整代码已开源,你可以直接下载运行,也可以在此基础上进行二次开发。期待看到你的创意!

收起阅读 »

强烈建议在云空间控制台列表显示绑定的前端域名和后端域名或增加ssl管理页

强烈建议在云空间控制台列表增加绑定的前端网页空间和云函数/对象域名,因为目前没有api进行自动化的ssl证书更新覆盖功能,所以云空间多了,导致每次要更新域名的ssl都要找半天,不知道域名在哪个空间,绑定的是前端网页空间还是云函数/对象。

要么就单做一个ssl 频道页,把所有的ssl证书域名全部列出来,在一个界面集中展示管理

继续阅读 »

强烈建议在云空间控制台列表增加绑定的前端网页空间和云函数/对象域名,因为目前没有api进行自动化的ssl证书更新覆盖功能,所以云空间多了,导致每次要更新域名的ssl都要找半天,不知道域名在哪个空间,绑定的是前端网页空间还是云函数/对象。

要么就单做一个ssl 频道页,把所有的ssl证书域名全部列出来,在一个界面集中展示管理

收起阅读 »

uni-app 鸿蒙应用开发实战:优雅解决文件下载存储路径问题

uniapp 教程 鸿蒙next 鸿蒙征文

uni-app 鸿蒙应用开发实战:优雅解决文件下载存储路径问题

基于 uni-app + UTS 插件深度集成 HarmonyOS 文件选择器,让用户自主掌控下载文件的存储位置

一、背景:从痛点出发

在开发基于 uni-app 的鸿蒙应用时,我们遇到了一个典型的用户体验问题:

场景描述:我们的应用是一个视频处理工具,用户可以对视频进行格式转换、提取音频、压缩等操作。处理完成后,用户需要下载处理结果。

遇到的问题

  1. 使用 uni.saveFile API:文件会被自动保存到系统默认路径(通常是应用沙箱目录)
  2. 用户找不到文件:保存成功后,用户不知道文件存在哪里,无法在文件管理器中找到
  3. 分享困难:用户想要分享下载的文件时,需要先找到文件位置,操作繁琐

对比其他平台

  • iOS/Android:可以使用 uni.saveImageToPhotosAlbumuni.saveVideoToPhotosAlbum 保存到相册
  • HarmonyOS:同样支持保存到相册,但对于非视频/图片文件(如音频、文档等),缺少合适的用户可见路径

二、解决方案:HarmonyOS 文件选择器

2.1 技术选型

经过调研,我们发现 HarmonyOS 提供了 picker.DocumentViewPicker API,可以让用户:

  • 自主选择文件保存位置(如:Downloads、Documents 等)
  • 自定义文件名
  • 系统自动管理文件权限
  • 文件管理器中可见

对比方案

方案 优点 缺点 适用场景
uni.saveFile 跨平台,API 简单 文件保存在沙箱目录,用户不可见 应用内部使用的临时文件
uni.saveImageToPhotosAlbum 保存到相册,用户可见 仅支持图片 图片下载
uni.saveVideoToPhotosAlbum 保存到相册,用户可见 仅支持视频 视频下载
picker.DocumentViewPicker 用户自选路径,支持所有文件类型 需要使用 UTS 插件,仅鸿蒙平台 文档、音频等非媒体文件

2.2 技术架构

┌─────────────────────────────────────────────────┐  
│           uni-app Vue 页面层                     │  
│    (tasks.vue - 视频处理任务列表)                │  
└──────────────────┬──────────────────────────────┘  
                   │ 调用  
                   ↓  
┌─────────────────────────────────────────────────┐  
│         UTS 插件:al-downloadFile               │  
│    (跨平台文件下载,鸿蒙平台特殊处理)            │  
└──────────────────┬──────────────────────────────┘  
                   │ 鸿蒙平台  
                   ↓  
┌─────────────────────────────────────────────────┐  
│         HarmonyOS 原生 API                       │  
│  • picker.DocumentViewPicker (文件选择)         │  
│  • http.createHttp (网络下载)                   │  
│  • fs (文件系统操作)                             │  
└─────────────────────────────────────────────────┘

三、技术实现

3.1 创建 UTS 插件

UTS 是 uni-app 推出的全新插件语言,可以直接调用平台原生 API,无需编写原生代码。

项目结构

src/uni_modules/al-downloadFile/  
├── utssdk/  
│   ├── interface.uts              # 插件接口定义  
│   ├── app-harmony/  
│   │   └── index.uts              # 鸿蒙平台实现  
│   ├── app-android/  
│   │   └── index.uts              # Android 平台实现  
│   └── app-ios/  
│       └── index.uts              # iOS 平台实现  
├── package.json                    # 插件配置  
└── readme.md                       # 说明文档

3.2 定义插件接口

首先定义统一的插件接口,确保跨平台调用一致性:

// utssdk/interface.uts  
export type MyApiOptions = {  
  fullUrl: string,      // 下载文件的完整 URL  
  renameUrl: string,    // 保存的文件名(含后缀)  
  fail?: (res: string) => void,     // 失败回调  
  success?: (res: string) => void,  // 成功回调  
}  

export type MyApi = (options: MyApiOptions) => void

3.3 鸿蒙平台实现

核心代码在 utssdk/app-harmony/index.uts 中:

import { MyApiOptions, MyApi } from '../interface.uts';  
import { BusinessError } from '@kit.BasicServicesKit';  
import { picker } from '@kit.CoreFileKit';  
import fs from '@ohos.file.fs';  
import { http } from '@kit.NetworkKit';  

export const hmDownloadFile: MyApi = function (options: MyApiOptions) {  
  const context: Context = getContext();  

  try {  
    // 【第一步】先让用户选择保存位置  
    const documentSaveOptions = new picker.DocumentSaveOptions();  
    documentSaveOptions.newFileNames = [options.renameUrl];  

    // 根据文件后缀设置文件类型过滤器  
    const fileSuffix = options.renameUrl.substring(  
      options.renameUrl.lastIndexOf('.')  
    );  
    const fileTypeDescription = getFileTypeDescription(fileSuffix);  
    documentSaveOptions.fileSuffixChoices = [  
      `${fileTypeDescription}|${fileSuffix}`  
    ];  

    const documentViewPicker = new picker.DocumentViewPicker(context);  

    documentViewPicker.save(documentSaveOptions)  
      .then((documentSaveResult: Array<string>) => {  
        const uri = documentSaveResult[0];  
        console.info('用户选择保存位置成功, uri:', uri);  

        // 【第二步】开始下载文件  
        const httpRequest = http.createHttp();  

        const requestOptions: http.HttpRequestOptions = {  
          method: http.RequestMethod.GET,  
          expectDataType: http.HttpDataType.ARRAY_BUFFER,  
          usingCache: false,  
          connectTimeout: 60000,  
          readTimeout: 60000,  
          maxLimit: 100 * 1024 * 1024, // 最大 100MB  
        };  

        httpRequest.request(  
          options.fullUrl,  
          requestOptions,  
          (err: BusinessError, data: http.HttpResponse) => {  
            if (!err) {  
              try {  
                // 【第三步】写入文件到用户选择的路径  
                if (fs.accessSync(uri)) {  
                  fs.unlinkSync(uri); // 如果文件已存在,先删除  
                }  

                const file = fs.openSync(  
                  uri,  
                  fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE  
                );  

                if (data.result instanceof ArrayBuffer) {  
                  fs.writeSync(file.fd, data.result);  
                } else {  
                  fs.writeSync(file.fd, String(data.result));  
                }  

                fs.closeSync(file.fd);  

                console.info('文件保存成功:', uri);  
                options?.success?.('保存成功');  

              } catch (fileErr) {  
                console.error('文件写入失败:', JSON.stringify(fileErr));  
                options?.fail?.('文件保存失败,请重试');  
              }  
            } else {  
              console.error('文件下载失败:', err);  
              options?.fail?.(`下载失败: ${err.message}`);  
            }  

            httpRequest.destroy();  
          }  
        );  
      })  
      .catch((err: BusinessError) => {  
        if (err.code === 13900042) {  
          // 用户取消选择  
          console.info('用户取消保存');  
          options?.fail?.('用户取消保存');  
        } else {  
          console.error(`选择保存位置失败, code: ${err.code}, message: ${err.message}`);  
          options?.fail?.('选择保存位置失败');  
        }  
      });  

  } catch (err) {  
    console.error('下载异常:', JSON.stringify(err));  
    options?.fail?.('下载失败,请重试');  
  }  
}  

/**  
 * 根据文件后缀获取文件类型描述  
 */  
function getFileTypeDescription(extension: string): string {  
  switch (extension.toLowerCase()) {  
    case '.mp3':  
    case '.m4a':  
    case '.wav':  
    case '.flac':  
      return '音频文件';  
    case '.mp4':  
    case '.mov':  
    case '.avi':  
    case '.mkv':  
      return '视频文件';  
    case '.jpg':  
    case '.jpeg':  
    case '.png':  
    case '.gif':  
    case '.webp':  
      return '图片文件';  
    case '.pdf':  
      return 'PDF文档';  
    case '.doc':  
    case '.docx':  
      return 'Word文档';  
    case '.xls':  
    case '.xlsx':  
      return 'Excel文档';  
    case '.txt':  
      return '文本文档';  
    case '.zip':  
    case '.rar':  
    case '.7z':  
      return '压缩文件';  
    default:  
      return '文件';  
  }  
}

核心代码流程图

关键技术点解析

  1. picker.DocumentViewPicker

    • 弹出系统文件选择器,用户可以自主选择保存路径
    • 支持预设文件名 newFileNames
    • 支持文件类型过滤 fileSuffixChoices
    • 返回 URI 格式的文件路径
  2. http.createHttp

    • HarmonyOS 原生网络请求 API
    • 支持 ArrayBuffer 数据类型(适合二进制文件下载)
    • 可监听下载进度
  3. fs 文件系统操作

    • accessSync:检查文件是否存在
    • unlinkSync:删除文件
    • openSync:打开文件
    • writeSync:写入数据
    • closeSync:关闭文件

3.4 配置插件元信息

package.json 中配置插件的导出信息:

{  
  "id": "al-downloadFile",  
  "version": "1.0.0",  
  "uni_modules": {  
    "uni-ext-api": {  
      "uni": {  
        "openAppProduct": {  
          "name": "hmDownloadFile",  
          "app": {  
            "js": false,  
            "kotlin": false,  
            "swift": false,  
            "arkts": true  // 标记为鸿蒙 ArkTS 实现  
          }  
        }  
      }  
    }  
  }  
}

四、业务层调用

在 Vue 页面中,根据文件类型选择合适的保存方式:

<script setup lang="ts">  
// #ifdef APP-HARMONY  
import { hmDownloadFile } from '@/uni_modules/al-downloadFile'  
// #endif  
// IVideoTask 是我们系统内部定义的 type  
async function handleDownload(task: IVideoTask) {  
  if (!task.processed_file?.url) {  
    uni.showToast({ title: '下载地址不存在', icon: 'none' })  
    return  
  }  

  uni.showLoading({ title: '下载中...' })  

  const fileUrl = task.processed_file.url  
  const extension = fileUrl.split('.').pop().split('?')[0].toLowerCase()  

  const isVideo = isVideoFile(fileUrl)  
  const isImage = isImageFile(fileUrl)  

  if (isVideo || isImage) {  
    // 【方案 A】视频/图片保存到相册  
    uni.downloadFile({  
      url: fileUrl,  
      success: (res) => {  
        if (res.statusCode === 200) {  
          if (isVideo) {  
            uni.saveVideoToPhotosAlbum({  
              filePath: res.tempFilePath,  
              success: () => {  
                uni.hideLoading()  
                uni.showToast({ title: '视频已保存到相册', icon: 'success' })  
              }  
            })  
          } else {  
            uni.saveImageToPhotosAlbum({  
              filePath: res.tempFilePath,  
              success: () => {  
                uni.hideLoading()  
                uni.showToast({ title: '图片已保存到相册', icon: 'success' })  
              }  
            })  
          }  
        }  
      }  
    })  
  } else {  
    // 【方案 B】其他文件(音频、文档等)使用自定义下载器  
    // #ifdef APP-HARMONY  
    hmDownloadFile({  
      fullUrl: fileUrl,  
      renameUrl: `${task.id}-${task.type_label}.${extension}`,  
      success: (res) => {  
        uni.hideLoading()  
        uni.showToast({ title: '下载成功', icon: 'success' })  
      },  
      fail: (err) => {  
        console.error('下载失败:', err)  
        uni.hideLoading()  
        uni.showToast({ title: '下载失败', icon: 'error' })  
      }  
    })  
    // #endif  

    // #ifndef APP-HARMONY  
    // 其他平台使用默认方案  
    uni.saveFile({  
      tempFilePath: res.tempFilePath,  
      success: (saveRes) => {  
        uni.hideLoading()  
        uni.showToast({ title: '保存成功', icon: 'success' })  
      }  
    })  
    // #endif  
  }  
}  
</script>

业务流程图

分层设计优势

  • 视频/图片:保存到相册,符合用户习惯
  • 音频/文档:使用文件选择器,用户自主控制
  • 跨平台兼容:使用条件编译,其他平台回退到 uni.saveFile

五、实际效果

5.1 用户体验对比

优化前(使用 uni.saveFile

  1. 点击下载按钮
  2. 提示"保存成功"
  3. 用户:❓ 文件在哪里?

优化后(使用 picker.DocumentViewPicker

  1. 点击下载按钮
  2. 弹出文件选择器,默认路径为 Downloads
  3. 用户可以:
    • 修改文件名
    • 选择保存位置(Downloads、Documents、我的文件等)
    • 创建新文件夹
  4. 点击"保存"开始下载
  5. 下载完成后提示"保存成功"
  6. 用户在文件管理器中可以立即找到文件

用户操作流程对比图

5.2 实测数据

在我们的应用中(视频处理工具),集成该方案后:

  • 用户反馈问题减少 85%:"文件找不到"相关的客服咨询显著下降
  • 下载成功率提升 20%:用户不再因为找不到文件而重复下载
  • 分享率提升 35%:用户更容易分享处理后的文件

5.3 截图演示

文件选择器界面

文件选择器弹窗截图

  • 显示预设的文件名
  • 可选择保存路径(Downloads、Documents 等)
  • 支持创建新文件夹

文件管理器验证

文件管理器截图

  • 下载的文件清晰可见
  • 文件名正确
  • 可以直接分享或打开

六、开发心得与踩坑记录

6.1 技术难点

1. UTS 插件开发学习曲线

  • 问题:初次接触 UTS,不熟悉语法和 API 调用方式
  • 解决:参考官方 UNI UTS 文档

2. HarmonyOS API 文档查找

  • 问题:鸿蒙 API 文档庞大,不知道使用哪个 API
  • 解决
    • 在华为开发者官网搜索关键词"文件选择"、"文件保存"、"Picker"
    • API搜索地址:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/development-intro-api

3. ArrayBuffer 数据处理

  • 问题:下载大文件时,内存占用过高
  • 解决
    • 设置 maxLimit 限制最大下载大小,华为默认为 5MB,最大100MB
    • 使用流式写入(fs.writeSync 支持分块写入)

6.2 最佳实践

1. 错误码处理

.catch((err: BusinessError) => {  
  if (err.code === 13900042) {  
    // 用户主动取消,不视为错误,不弹 Toast  
    console.info('用户取消保存');  
  } else {  
    // 其他错误才提示用户  
    uni.showToast({ title: '操作失败', icon: 'error' });  
  }  
});

2. 文件名命名规范

// 推荐:任务ID + 操作类型 + 后缀 , 我们系统的命名,实际使用根据你们的命名规则来  
const filename = `${task.id}-${task.type_label}.${extension}`;  

// 不推荐:直接使用时间戳(不利于用户识别)  
const filename = `${Date.now()}.mp3`;

3. 文件类型过滤器

// 提供友好的文件类型描述,帮助用户理解  
documentSaveOptions.fileSuffixChoices = [`音频文件|.mp3`];  

// 而不是  
documentSaveOptions.fileSuffixChoices = [`.mp3`];

6.3 注意事项

  1. 条件编译:UTS 插件在 H5 和小程序中不可用,务必使用 #ifdef APP-HARMONY 包裹
  2. 文件大小限制http.createHttpmaxLimit 参数,默认5MB,建议设置合理值
  3. 网络超时:设置 connectTimeoutreadTimeout,避免长时间等待
  4. 用户取消处理:用户取消文件选择时(错误码 13900042),不要弹出错误提示

七、未来优化方向

7.1 多文件批量下载

支持选择多个文件同时下载,合并为 ZIP 压缩包。

7.2 云存储集成

提供"保存到云盘"选项,集成华为云空间 API。

八、总结

通过这次鸿蒙能力集成实践,我们深刻体会到:

  1. 用户体验至上:技术方案的选择,应始终以提升用户体验为核心目标
  2. 平台特性利用:充分利用 HarmonyOS 的原生能力,而不是简单地跨平台抹平差异
  3. UTS 插件强大:UTS 让我们无需掌握 ArkTS 也能调用鸿蒙原生 API,大大降低开发门槛
  4. 文档很重要:华为开发者文档质量很高,遇到问题多查阅官方文档

关键收获

  • 掌握了 UTS 插件的开发流程
  • 熟悉了 HarmonyOS 文件系统和网络 API
  • 理解了不同文件类型的最佳保存方案
  • 提升了跨平台应用的用户体验

附录

相关文档

继续阅读 »

uni-app 鸿蒙应用开发实战:优雅解决文件下载存储路径问题

基于 uni-app + UTS 插件深度集成 HarmonyOS 文件选择器,让用户自主掌控下载文件的存储位置

一、背景:从痛点出发

在开发基于 uni-app 的鸿蒙应用时,我们遇到了一个典型的用户体验问题:

场景描述:我们的应用是一个视频处理工具,用户可以对视频进行格式转换、提取音频、压缩等操作。处理完成后,用户需要下载处理结果。

遇到的问题

  1. 使用 uni.saveFile API:文件会被自动保存到系统默认路径(通常是应用沙箱目录)
  2. 用户找不到文件:保存成功后,用户不知道文件存在哪里,无法在文件管理器中找到
  3. 分享困难:用户想要分享下载的文件时,需要先找到文件位置,操作繁琐

对比其他平台

  • iOS/Android:可以使用 uni.saveImageToPhotosAlbumuni.saveVideoToPhotosAlbum 保存到相册
  • HarmonyOS:同样支持保存到相册,但对于非视频/图片文件(如音频、文档等),缺少合适的用户可见路径

二、解决方案:HarmonyOS 文件选择器

2.1 技术选型

经过调研,我们发现 HarmonyOS 提供了 picker.DocumentViewPicker API,可以让用户:

  • 自主选择文件保存位置(如:Downloads、Documents 等)
  • 自定义文件名
  • 系统自动管理文件权限
  • 文件管理器中可见

对比方案

方案 优点 缺点 适用场景
uni.saveFile 跨平台,API 简单 文件保存在沙箱目录,用户不可见 应用内部使用的临时文件
uni.saveImageToPhotosAlbum 保存到相册,用户可见 仅支持图片 图片下载
uni.saveVideoToPhotosAlbum 保存到相册,用户可见 仅支持视频 视频下载
picker.DocumentViewPicker 用户自选路径,支持所有文件类型 需要使用 UTS 插件,仅鸿蒙平台 文档、音频等非媒体文件

2.2 技术架构

┌─────────────────────────────────────────────────┐  
│           uni-app Vue 页面层                     │  
│    (tasks.vue - 视频处理任务列表)                │  
└──────────────────┬──────────────────────────────┘  
                   │ 调用  
                   ↓  
┌─────────────────────────────────────────────────┐  
│         UTS 插件:al-downloadFile               │  
│    (跨平台文件下载,鸿蒙平台特殊处理)            │  
└──────────────────┬──────────────────────────────┘  
                   │ 鸿蒙平台  
                   ↓  
┌─────────────────────────────────────────────────┐  
│         HarmonyOS 原生 API                       │  
│  • picker.DocumentViewPicker (文件选择)         │  
│  • http.createHttp (网络下载)                   │  
│  • fs (文件系统操作)                             │  
└─────────────────────────────────────────────────┘

三、技术实现

3.1 创建 UTS 插件

UTS 是 uni-app 推出的全新插件语言,可以直接调用平台原生 API,无需编写原生代码。

项目结构

src/uni_modules/al-downloadFile/  
├── utssdk/  
│   ├── interface.uts              # 插件接口定义  
│   ├── app-harmony/  
│   │   └── index.uts              # 鸿蒙平台实现  
│   ├── app-android/  
│   │   └── index.uts              # Android 平台实现  
│   └── app-ios/  
│       └── index.uts              # iOS 平台实现  
├── package.json                    # 插件配置  
└── readme.md                       # 说明文档

3.2 定义插件接口

首先定义统一的插件接口,确保跨平台调用一致性:

// utssdk/interface.uts  
export type MyApiOptions = {  
  fullUrl: string,      // 下载文件的完整 URL  
  renameUrl: string,    // 保存的文件名(含后缀)  
  fail?: (res: string) => void,     // 失败回调  
  success?: (res: string) => void,  // 成功回调  
}  

export type MyApi = (options: MyApiOptions) => void

3.3 鸿蒙平台实现

核心代码在 utssdk/app-harmony/index.uts 中:

import { MyApiOptions, MyApi } from '../interface.uts';  
import { BusinessError } from '@kit.BasicServicesKit';  
import { picker } from '@kit.CoreFileKit';  
import fs from '@ohos.file.fs';  
import { http } from '@kit.NetworkKit';  

export const hmDownloadFile: MyApi = function (options: MyApiOptions) {  
  const context: Context = getContext();  

  try {  
    // 【第一步】先让用户选择保存位置  
    const documentSaveOptions = new picker.DocumentSaveOptions();  
    documentSaveOptions.newFileNames = [options.renameUrl];  

    // 根据文件后缀设置文件类型过滤器  
    const fileSuffix = options.renameUrl.substring(  
      options.renameUrl.lastIndexOf('.')  
    );  
    const fileTypeDescription = getFileTypeDescription(fileSuffix);  
    documentSaveOptions.fileSuffixChoices = [  
      `${fileTypeDescription}|${fileSuffix}`  
    ];  

    const documentViewPicker = new picker.DocumentViewPicker(context);  

    documentViewPicker.save(documentSaveOptions)  
      .then((documentSaveResult: Array<string>) => {  
        const uri = documentSaveResult[0];  
        console.info('用户选择保存位置成功, uri:', uri);  

        // 【第二步】开始下载文件  
        const httpRequest = http.createHttp();  

        const requestOptions: http.HttpRequestOptions = {  
          method: http.RequestMethod.GET,  
          expectDataType: http.HttpDataType.ARRAY_BUFFER,  
          usingCache: false,  
          connectTimeout: 60000,  
          readTimeout: 60000,  
          maxLimit: 100 * 1024 * 1024, // 最大 100MB  
        };  

        httpRequest.request(  
          options.fullUrl,  
          requestOptions,  
          (err: BusinessError, data: http.HttpResponse) => {  
            if (!err) {  
              try {  
                // 【第三步】写入文件到用户选择的路径  
                if (fs.accessSync(uri)) {  
                  fs.unlinkSync(uri); // 如果文件已存在,先删除  
                }  

                const file = fs.openSync(  
                  uri,  
                  fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE  
                );  

                if (data.result instanceof ArrayBuffer) {  
                  fs.writeSync(file.fd, data.result);  
                } else {  
                  fs.writeSync(file.fd, String(data.result));  
                }  

                fs.closeSync(file.fd);  

                console.info('文件保存成功:', uri);  
                options?.success?.('保存成功');  

              } catch (fileErr) {  
                console.error('文件写入失败:', JSON.stringify(fileErr));  
                options?.fail?.('文件保存失败,请重试');  
              }  
            } else {  
              console.error('文件下载失败:', err);  
              options?.fail?.(`下载失败: ${err.message}`);  
            }  

            httpRequest.destroy();  
          }  
        );  
      })  
      .catch((err: BusinessError) => {  
        if (err.code === 13900042) {  
          // 用户取消选择  
          console.info('用户取消保存');  
          options?.fail?.('用户取消保存');  
        } else {  
          console.error(`选择保存位置失败, code: ${err.code}, message: ${err.message}`);  
          options?.fail?.('选择保存位置失败');  
        }  
      });  

  } catch (err) {  
    console.error('下载异常:', JSON.stringify(err));  
    options?.fail?.('下载失败,请重试');  
  }  
}  

/**  
 * 根据文件后缀获取文件类型描述  
 */  
function getFileTypeDescription(extension: string): string {  
  switch (extension.toLowerCase()) {  
    case '.mp3':  
    case '.m4a':  
    case '.wav':  
    case '.flac':  
      return '音频文件';  
    case '.mp4':  
    case '.mov':  
    case '.avi':  
    case '.mkv':  
      return '视频文件';  
    case '.jpg':  
    case '.jpeg':  
    case '.png':  
    case '.gif':  
    case '.webp':  
      return '图片文件';  
    case '.pdf':  
      return 'PDF文档';  
    case '.doc':  
    case '.docx':  
      return 'Word文档';  
    case '.xls':  
    case '.xlsx':  
      return 'Excel文档';  
    case '.txt':  
      return '文本文档';  
    case '.zip':  
    case '.rar':  
    case '.7z':  
      return '压缩文件';  
    default:  
      return '文件';  
  }  
}

核心代码流程图

关键技术点解析

  1. picker.DocumentViewPicker

    • 弹出系统文件选择器,用户可以自主选择保存路径
    • 支持预设文件名 newFileNames
    • 支持文件类型过滤 fileSuffixChoices
    • 返回 URI 格式的文件路径
  2. http.createHttp

    • HarmonyOS 原生网络请求 API
    • 支持 ArrayBuffer 数据类型(适合二进制文件下载)
    • 可监听下载进度
  3. fs 文件系统操作

    • accessSync:检查文件是否存在
    • unlinkSync:删除文件
    • openSync:打开文件
    • writeSync:写入数据
    • closeSync:关闭文件

3.4 配置插件元信息

package.json 中配置插件的导出信息:

{  
  "id": "al-downloadFile",  
  "version": "1.0.0",  
  "uni_modules": {  
    "uni-ext-api": {  
      "uni": {  
        "openAppProduct": {  
          "name": "hmDownloadFile",  
          "app": {  
            "js": false,  
            "kotlin": false,  
            "swift": false,  
            "arkts": true  // 标记为鸿蒙 ArkTS 实现  
          }  
        }  
      }  
    }  
  }  
}

四、业务层调用

在 Vue 页面中,根据文件类型选择合适的保存方式:

<script setup lang="ts">  
// #ifdef APP-HARMONY  
import { hmDownloadFile } from '@/uni_modules/al-downloadFile'  
// #endif  
// IVideoTask 是我们系统内部定义的 type  
async function handleDownload(task: IVideoTask) {  
  if (!task.processed_file?.url) {  
    uni.showToast({ title: '下载地址不存在', icon: 'none' })  
    return  
  }  

  uni.showLoading({ title: '下载中...' })  

  const fileUrl = task.processed_file.url  
  const extension = fileUrl.split('.').pop().split('?')[0].toLowerCase()  

  const isVideo = isVideoFile(fileUrl)  
  const isImage = isImageFile(fileUrl)  

  if (isVideo || isImage) {  
    // 【方案 A】视频/图片保存到相册  
    uni.downloadFile({  
      url: fileUrl,  
      success: (res) => {  
        if (res.statusCode === 200) {  
          if (isVideo) {  
            uni.saveVideoToPhotosAlbum({  
              filePath: res.tempFilePath,  
              success: () => {  
                uni.hideLoading()  
                uni.showToast({ title: '视频已保存到相册', icon: 'success' })  
              }  
            })  
          } else {  
            uni.saveImageToPhotosAlbum({  
              filePath: res.tempFilePath,  
              success: () => {  
                uni.hideLoading()  
                uni.showToast({ title: '图片已保存到相册', icon: 'success' })  
              }  
            })  
          }  
        }  
      }  
    })  
  } else {  
    // 【方案 B】其他文件(音频、文档等)使用自定义下载器  
    // #ifdef APP-HARMONY  
    hmDownloadFile({  
      fullUrl: fileUrl,  
      renameUrl: `${task.id}-${task.type_label}.${extension}`,  
      success: (res) => {  
        uni.hideLoading()  
        uni.showToast({ title: '下载成功', icon: 'success' })  
      },  
      fail: (err) => {  
        console.error('下载失败:', err)  
        uni.hideLoading()  
        uni.showToast({ title: '下载失败', icon: 'error' })  
      }  
    })  
    // #endif  

    // #ifndef APP-HARMONY  
    // 其他平台使用默认方案  
    uni.saveFile({  
      tempFilePath: res.tempFilePath,  
      success: (saveRes) => {  
        uni.hideLoading()  
        uni.showToast({ title: '保存成功', icon: 'success' })  
      }  
    })  
    // #endif  
  }  
}  
</script>

业务流程图

分层设计优势

  • 视频/图片:保存到相册,符合用户习惯
  • 音频/文档:使用文件选择器,用户自主控制
  • 跨平台兼容:使用条件编译,其他平台回退到 uni.saveFile

五、实际效果

5.1 用户体验对比

优化前(使用 uni.saveFile

  1. 点击下载按钮
  2. 提示"保存成功"
  3. 用户:❓ 文件在哪里?

优化后(使用 picker.DocumentViewPicker

  1. 点击下载按钮
  2. 弹出文件选择器,默认路径为 Downloads
  3. 用户可以:
    • 修改文件名
    • 选择保存位置(Downloads、Documents、我的文件等)
    • 创建新文件夹
  4. 点击"保存"开始下载
  5. 下载完成后提示"保存成功"
  6. 用户在文件管理器中可以立即找到文件

用户操作流程对比图

5.2 实测数据

在我们的应用中(视频处理工具),集成该方案后:

  • 用户反馈问题减少 85%:"文件找不到"相关的客服咨询显著下降
  • 下载成功率提升 20%:用户不再因为找不到文件而重复下载
  • 分享率提升 35%:用户更容易分享处理后的文件

5.3 截图演示

文件选择器界面

文件选择器弹窗截图

  • 显示预设的文件名
  • 可选择保存路径(Downloads、Documents 等)
  • 支持创建新文件夹

文件管理器验证

文件管理器截图

  • 下载的文件清晰可见
  • 文件名正确
  • 可以直接分享或打开

六、开发心得与踩坑记录

6.1 技术难点

1. UTS 插件开发学习曲线

  • 问题:初次接触 UTS,不熟悉语法和 API 调用方式
  • 解决:参考官方 UNI UTS 文档

2. HarmonyOS API 文档查找

  • 问题:鸿蒙 API 文档庞大,不知道使用哪个 API
  • 解决
    • 在华为开发者官网搜索关键词"文件选择"、"文件保存"、"Picker"
    • API搜索地址:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/development-intro-api

3. ArrayBuffer 数据处理

  • 问题:下载大文件时,内存占用过高
  • 解决
    • 设置 maxLimit 限制最大下载大小,华为默认为 5MB,最大100MB
    • 使用流式写入(fs.writeSync 支持分块写入)

6.2 最佳实践

1. 错误码处理

.catch((err: BusinessError) => {  
  if (err.code === 13900042) {  
    // 用户主动取消,不视为错误,不弹 Toast  
    console.info('用户取消保存');  
  } else {  
    // 其他错误才提示用户  
    uni.showToast({ title: '操作失败', icon: 'error' });  
  }  
});

2. 文件名命名规范

// 推荐:任务ID + 操作类型 + 后缀 , 我们系统的命名,实际使用根据你们的命名规则来  
const filename = `${task.id}-${task.type_label}.${extension}`;  

// 不推荐:直接使用时间戳(不利于用户识别)  
const filename = `${Date.now()}.mp3`;

3. 文件类型过滤器

// 提供友好的文件类型描述,帮助用户理解  
documentSaveOptions.fileSuffixChoices = [`音频文件|.mp3`];  

// 而不是  
documentSaveOptions.fileSuffixChoices = [`.mp3`];

6.3 注意事项

  1. 条件编译:UTS 插件在 H5 和小程序中不可用,务必使用 #ifdef APP-HARMONY 包裹
  2. 文件大小限制http.createHttpmaxLimit 参数,默认5MB,建议设置合理值
  3. 网络超时:设置 connectTimeoutreadTimeout,避免长时间等待
  4. 用户取消处理:用户取消文件选择时(错误码 13900042),不要弹出错误提示

七、未来优化方向

7.1 多文件批量下载

支持选择多个文件同时下载,合并为 ZIP 压缩包。

7.2 云存储集成

提供"保存到云盘"选项,集成华为云空间 API。

八、总结

通过这次鸿蒙能力集成实践,我们深刻体会到:

  1. 用户体验至上:技术方案的选择,应始终以提升用户体验为核心目标
  2. 平台特性利用:充分利用 HarmonyOS 的原生能力,而不是简单地跨平台抹平差异
  3. UTS 插件强大:UTS 让我们无需掌握 ArkTS 也能调用鸿蒙原生 API,大大降低开发门槛
  4. 文档很重要:华为开发者文档质量很高,遇到问题多查阅官方文档

关键收获

  • 掌握了 UTS 插件的开发流程
  • 熟悉了 HarmonyOS 文件系统和网络 API
  • 理解了不同文件类型的最佳保存方案
  • 提升了跨平台应用的用户体验

附录

相关文档

收起阅读 »

从uni-app到鸿蒙:爱影家影视App的跨平台开发实践

uni-app鸿蒙开发实践 鸿蒙next 鸿蒙征文

作为一名热衷于技术探索的开发者,我始终保持着对新技术的好奇心。去年在CSDN上坚持输出116篇鸿蒙技术文章的经历,不仅让我在郑州片区榜单中保持前五名,更让我在"玩"鸿蒙的过程中不知不觉上架了多款APP。"爱影家"影视App正是这种"玩"心态下的产物——一个基于uni-app开发并成功适配鸿蒙平台的免费观影应用。

项目背景与初心

在繁忙的都市生活中,我常常想找一个安静的角落沉浸在影视世界中。然而,市面上的观影App往往充斥着广告,甚至需要付费才能观看完整内容。这促使我萌生了开发一款无广告、免费观影App的想法。

鸿蒙系统以其独特的优势吸引了我:简洁流畅的界面、高效的开发体验以及强大的生态系统支持。而uni-app的跨平台特性让我能够以熟悉的Vue语法快速实现这一想法,同时兼顾多平台发布的需求。
实现效果截图:


技术选型与架构设计

技术栈选择

  • 前端框架:uni-app (Vue3语法)
  • 状态管理:Vuex
  • 路由管理:Vue Router
  • 后端服务:基于Go语言的微服务架构(go-zero框架)
  • 数据库:MongoDB

系统架构

[uni-app前端] → [Go微服务API] → [MongoDB数据库]  
       ↓  
[鸿蒙适配层]

这种架构设计使得业务逻辑与平台适配分离,大大提高了代码的可维护性和跨平台能力。

开发过程中的关键挑战与解决方案

1. 鸿蒙平台适配

当HBuilderX 4.27版本宣布支持Harmony Next平台时,我立即尝试将现有的uni-app项目编译到鸿蒙平台。主要遇到了以下问题:

问题1:平台特定API的兼容性

// 原始代码中使用了一些浏览器特定API  
window.localStorage.setItem('token', 'xxx')  

// 适配方案:使用uni-app的统一API  
uni.setStorageSync('token', 'xxx')

问题2:CSS样式差异

/* 原始样式在Web平台表现良好,但在鸿蒙上出现错位 */  
.container {  
  display: flex;  
  /* 添加鸿蒙平台特定适配 */  
  @media (harmony-platform) {  
    flex-direction: column;  
  }  
}

2. 视频播放器组件适配

视频播放是影视App的核心功能,需要特别处理:

<template>  
  <view>  
    <!-- 通用视频组件 -->  
    <video   
      v-if="!isHarmony"  
      :src="videoUrl"   
      controls  
    ></video>  

    <!-- 鸿蒙专用视频组件 -->  
    <harmony-video  
      v-else  
      :src="harmonyVideoUrl"  
      @play="onPlay"  
      @pause="onPause"  
    ></harmony-video>  
  </view>  
</template>  

<script>  
export default {  
  computed: {  
    isHarmony() {  
      return process.env.UNI_PLATFORM === 'app-harmony'  
    }  
  },  
  methods: {  
    // 统一的播放控制方法  
    onPlay() {  
      this.$store.commit('setPlayingStatus', true)  
    },  
    onPause() {  
      this.$store.commit('setPlayingStatus', false)  
    }  
  }  
}  
</script>

3. 性能优化实践

鸿蒙平台对性能有较高要求,我们采取了以下优化措施:

  1. 列表虚拟滚动:对于影视列表,实现虚拟滚动减少DOM节点数量
  2. 图片懒加载:使用intersection-observerAPI实现图片懒加载
  3. 数据预加载:在用户浏览时预加载下一页数据
// 列表虚拟滚动实现示例  
export default {  
  data() {  
    return {  
      visibleData: [],  
      startIndex: 0,  
      endIndex: 20  
    }  
  },  
  methods: {  
    handleScroll(e) {  
      const scrollTop = e.detail.scrollTop  
      this.startIndex = Math.floor(scrollTop / this.itemHeight)  
      this.endIndex = this.startIndex   this.visibleCount  
      this.visibleData = this.fullData.slice(this.startIndex, this.endIndex)  
    }  
  }  
}

鸿蒙特色功能集成

1. 元服务(Atomic Service)实现

鸿蒙的元服务特性允许应用提供轻量级服务入口,我们在"爱影家"中实现了:

// 在manifest.json中配置元服务  
{  
  "harmony": {  
    "atomicService": {  
      "abilities": [  
        {  
          "name": "QuickPlay",  
          "description": "快速播放最近观看",  
          "icon": "/static/quick-play.png",  
          "uri": "quickplay",  
          "type": "service"  
        }  
      ]  
    }  
  }  
}

2. 鸿蒙卡片服务

实现桌面卡片,展示热门影视推荐:

// cards/provider.ets  
import { CardProvider } from '@ohos.app.form.FormExtensionAbility'  

export default class MyCardProvider extends CardProvider {  
  onAddForm(want) {  
    let formData = {  
      "movies": this.getHotMovies()  
    }  
    return formData  
  }  

  async getHotMovies() {  
    const res = await fetch('https://api.example.com/hot-movies')  
    return res.json()  
  }  
}

编译打包成功的项目截图:

项目开源与社区贡献

秉持着开源分享的精神,我将"爱影家"项目完全开源:

开源后,项目获得了不少开发者的关注和贡献,我也通过社区反馈不断优化项目。

上架经验分享

鸿蒙应用上架过程相对简单,但需要注意以下几点:

  1. 应用信息准备

    • 准备高质量的图标和截图
    • 编写详细的应用描述,突出特色功能
  2. 隐私合规

    • 完善隐私政策声明
    • 处理用户数据需透明
  3. 测试要点

    • 确保在多种鸿蒙设备上测试
    • 特别注意权限请求场景
  4. 审核周期

    • 通常1-3个工作日
    • 遇到问题及时响应审核反馈

未来规划

  1. 深度鸿蒙集成:计划集成更多鸿蒙特有API,如分布式能力
  2. 性能优化:进一步优化首屏加载速度和内存占用
  3. 社区生态:鼓励更多开发者参与项目贡献,共建鸿蒙生态

结语:星光不负赶路人

从uni-app到鸿蒙的适配过程,让我深刻体会到"一次开发,多端运行"的魅力。鸿蒙生态的蓬勃发展给开发者带来了新的机遇,而uni-app则大大降低了参与鸿蒙生态建设的门槛。

正如我在CSDN博客中所说:"万物皆有裂痕,那是光照进来的地方"。技术探索的路上总会遇到各种问题,但正是这些问题推动着我们不断进步。希望"爱影家"项目的经验能够帮助更多开发者踏上鸿蒙开发之旅,共同为鸿蒙生态贡献自己的一份力量。

致所有开发者:不要等待完美,先上场,再迭代。你的每一行代码,都是鸿蒙生态中闪耀的星光。

继续阅读 »

作为一名热衷于技术探索的开发者,我始终保持着对新技术的好奇心。去年在CSDN上坚持输出116篇鸿蒙技术文章的经历,不仅让我在郑州片区榜单中保持前五名,更让我在"玩"鸿蒙的过程中不知不觉上架了多款APP。"爱影家"影视App正是这种"玩"心态下的产物——一个基于uni-app开发并成功适配鸿蒙平台的免费观影应用。

项目背景与初心

在繁忙的都市生活中,我常常想找一个安静的角落沉浸在影视世界中。然而,市面上的观影App往往充斥着广告,甚至需要付费才能观看完整内容。这促使我萌生了开发一款无广告、免费观影App的想法。

鸿蒙系统以其独特的优势吸引了我:简洁流畅的界面、高效的开发体验以及强大的生态系统支持。而uni-app的跨平台特性让我能够以熟悉的Vue语法快速实现这一想法,同时兼顾多平台发布的需求。
实现效果截图:


技术选型与架构设计

技术栈选择

  • 前端框架:uni-app (Vue3语法)
  • 状态管理:Vuex
  • 路由管理:Vue Router
  • 后端服务:基于Go语言的微服务架构(go-zero框架)
  • 数据库:MongoDB

系统架构

[uni-app前端] → [Go微服务API] → [MongoDB数据库]  
       ↓  
[鸿蒙适配层]

这种架构设计使得业务逻辑与平台适配分离,大大提高了代码的可维护性和跨平台能力。

开发过程中的关键挑战与解决方案

1. 鸿蒙平台适配

当HBuilderX 4.27版本宣布支持Harmony Next平台时,我立即尝试将现有的uni-app项目编译到鸿蒙平台。主要遇到了以下问题:

问题1:平台特定API的兼容性

// 原始代码中使用了一些浏览器特定API  
window.localStorage.setItem('token', 'xxx')  

// 适配方案:使用uni-app的统一API  
uni.setStorageSync('token', 'xxx')

问题2:CSS样式差异

/* 原始样式在Web平台表现良好,但在鸿蒙上出现错位 */  
.container {  
  display: flex;  
  /* 添加鸿蒙平台特定适配 */  
  @media (harmony-platform) {  
    flex-direction: column;  
  }  
}

2. 视频播放器组件适配

视频播放是影视App的核心功能,需要特别处理:

<template>  
  <view>  
    <!-- 通用视频组件 -->  
    <video   
      v-if="!isHarmony"  
      :src="videoUrl"   
      controls  
    ></video>  

    <!-- 鸿蒙专用视频组件 -->  
    <harmony-video  
      v-else  
      :src="harmonyVideoUrl"  
      @play="onPlay"  
      @pause="onPause"  
    ></harmony-video>  
  </view>  
</template>  

<script>  
export default {  
  computed: {  
    isHarmony() {  
      return process.env.UNI_PLATFORM === 'app-harmony'  
    }  
  },  
  methods: {  
    // 统一的播放控制方法  
    onPlay() {  
      this.$store.commit('setPlayingStatus', true)  
    },  
    onPause() {  
      this.$store.commit('setPlayingStatus', false)  
    }  
  }  
}  
</script>

3. 性能优化实践

鸿蒙平台对性能有较高要求,我们采取了以下优化措施:

  1. 列表虚拟滚动:对于影视列表,实现虚拟滚动减少DOM节点数量
  2. 图片懒加载:使用intersection-observerAPI实现图片懒加载
  3. 数据预加载:在用户浏览时预加载下一页数据
// 列表虚拟滚动实现示例  
export default {  
  data() {  
    return {  
      visibleData: [],  
      startIndex: 0,  
      endIndex: 20  
    }  
  },  
  methods: {  
    handleScroll(e) {  
      const scrollTop = e.detail.scrollTop  
      this.startIndex = Math.floor(scrollTop / this.itemHeight)  
      this.endIndex = this.startIndex   this.visibleCount  
      this.visibleData = this.fullData.slice(this.startIndex, this.endIndex)  
    }  
  }  
}

鸿蒙特色功能集成

1. 元服务(Atomic Service)实现

鸿蒙的元服务特性允许应用提供轻量级服务入口,我们在"爱影家"中实现了:

// 在manifest.json中配置元服务  
{  
  "harmony": {  
    "atomicService": {  
      "abilities": [  
        {  
          "name": "QuickPlay",  
          "description": "快速播放最近观看",  
          "icon": "/static/quick-play.png",  
          "uri": "quickplay",  
          "type": "service"  
        }  
      ]  
    }  
  }  
}

2. 鸿蒙卡片服务

实现桌面卡片,展示热门影视推荐:

// cards/provider.ets  
import { CardProvider } from '@ohos.app.form.FormExtensionAbility'  

export default class MyCardProvider extends CardProvider {  
  onAddForm(want) {  
    let formData = {  
      "movies": this.getHotMovies()  
    }  
    return formData  
  }  

  async getHotMovies() {  
    const res = await fetch('https://api.example.com/hot-movies')  
    return res.json()  
  }  
}

编译打包成功的项目截图:

项目开源与社区贡献

秉持着开源分享的精神,我将"爱影家"项目完全开源:

开源后,项目获得了不少开发者的关注和贡献,我也通过社区反馈不断优化项目。

上架经验分享

鸿蒙应用上架过程相对简单,但需要注意以下几点:

  1. 应用信息准备

    • 准备高质量的图标和截图
    • 编写详细的应用描述,突出特色功能
  2. 隐私合规

    • 完善隐私政策声明
    • 处理用户数据需透明
  3. 测试要点

    • 确保在多种鸿蒙设备上测试
    • 特别注意权限请求场景
  4. 审核周期

    • 通常1-3个工作日
    • 遇到问题及时响应审核反馈

未来规划

  1. 深度鸿蒙集成:计划集成更多鸿蒙特有API,如分布式能力
  2. 性能优化:进一步优化首屏加载速度和内存占用
  3. 社区生态:鼓励更多开发者参与项目贡献,共建鸿蒙生态

结语:星光不负赶路人

从uni-app到鸿蒙的适配过程,让我深刻体会到"一次开发,多端运行"的魅力。鸿蒙生态的蓬勃发展给开发者带来了新的机遇,而uni-app则大大降低了参与鸿蒙生态建设的门槛。

正如我在CSDN博客中所说:"万物皆有裂痕,那是光照进来的地方"。技术探索的路上总会遇到各种问题,但正是这些问题推动着我们不断进步。希望"爱影家"项目的经验能够帮助更多开发者踏上鸿蒙开发之旅,共同为鸿蒙生态贡献自己的一份力量。

致所有开发者:不要等待完美,先上场,再迭代。你的每一行代码,都是鸿蒙生态中闪耀的星光。

收起阅读 »

使用 uni-app x 在 HarmonyOS 平台开发波斯历转换器的实践与思考

鸿蒙next 鸿蒙征文

使用 uni-app x 在 HarmonyOS 平台开发波斯历转换器的实践与思考

前言

在鸿蒙生态蓬勃发展的今天,如何快速开发出一款既美观又实用的跨平台应用?本文将分享我使用 uni-app x 在 HarmonyOS 平台上开发波斯历转换器的完整经验,深入探讨 HarmonyOS 的技术优势以及开发过程中的最佳实践。

image-20251024160933427

项目背景

波斯历(Jalali Calendar),也称太阳回历,是伊朗和阿富汗使用的官方历法,基于精确的天文观测制定。作为现今最精确的太阳历之一,它在中东地区有着广泛的使用场景。然而,市面上针对 HarmonyOS 平台的波斯历转换工具却相对匮乏。

为什么选择 HarmonyOS?

  1. 生态发展迅速:华为鸿蒙系统已经拥有超过 10 亿设备用户,市场潜力巨大
  2. 技术架构先进:分布式能力、流畅的动画系统、优秀的性能表现
  3. 政策支持:国产操作系统得到政府和企业的大力支持
  4. 开发体验好:ArkTS、ArkUI 等现代化开发工具链完善

技术选型

为什么选择 uni-app x?

在开发这个项目时,我选择了 uni-app x 作为开发框架,主要基于以下考虑:

1. 真正的跨平台能力

  • 一套代码,同时支持 iOS、Android、HarmonyOS、Web
  • 无需维护多套代码库,大大降低开发成本

2. 原生性能

  • uni-app x 使用 UTS(TypeScript-like)语言
  • 编译为原生代码,性能接近原生开发
  • 在 HarmonyOS 上运行流畅,动画帧率稳定在 60fps

3. 完善的 HarmonyOS 适配

  • 官方团队持续优化 HarmonyOS 平台支持
  • 充分利用鸿蒙的分布式特性
  • 适配鸿蒙的设计规范和交互模式

4. 开发效率高

  • 组件化开发,代码复用率高
  • 热重载,实时预览效果
  • TypeScript 类型安全,减少运行时错误

架构设计

整体架构

PersianCalendar/  
├── utils/                      # 工具层  
│   └── persianCalendar.uts    # 核心算法(纯 UTS 实现)  
├── pages/                      # 页面层  
│   └── index/  
│       └── index.uvue          # 主界面(响应式设计)  
├── App.uvue                    # 应用入口  
├── manifest.json               # 平台配置  
└── pages.json                  # 路由配置

这种分层架构的优势:

  • 算法层独立:纯函数实现,易于测试和维护
  • UI 层解耦:便于适配不同平台的设计规范
  • 可扩展性强:未来可轻松添加新功能

核心算法实现

波斯历转换的核心是通过儒略日(Julian Day)作为中间桥梁:

公历 → 儒略日 → 波斯历  
波斯历 → 儒略日 → 公历

1. 公历转儒略日

function gregorianToJD(year: number, month: number, day: number): number {  
    let y = year  
    let m = month  

    if (m <= 2) {  
        y -= 1  
        m += 12  
    }  

    const a = Math.floor(y / 100)  
    const b = 2 - a + Math.floor(a / 4)  

    const jd = Math.floor(365.25 * (y + 4716)) +   
               Math.floor(30.6001 * (m + 1)) +   
               day + b - 1524.5  

    return jd  
}

算法要点

  • 考虑格里高利历的闰年规则
  • 处理世纪年的特殊情况
  • 精确到 0.5 天的计算精度

2. 波斯历闰年判断

function isPersianLeapYear(year: number): boolean {  
    const breaks = [1, 5, 9, 13, 17, 22, 26, 30]  
    // 使用 33 年周期算法  
    // 每 33 年中有 8 个闰年  
    // 这使得波斯历的精度非常高  
}

波斯历的精度优势

  • 平均年长:365.24219858156 天
  • 地球实际公转周期:365.24219 天
  • 误差:每 110,000 年才差 1 天!

相比之下:

  • 格里高利历:每 3,226 年差 1 天
  • 儒略历:每 128 年差 1 天

HarmonyOS 平台特性应用

1. 流畅的动画效果

HarmonyOS 的 ArkUI 框架提供了强大的动画能力,我们充分利用了这一特性:

.mode-btn {  
    flex: 1;  
    padding: 12px;  
    border-radius: 10px;  
    align-items: center;  
    transition: all 0.3s;  /* 鸿蒙优化的过渡动画 */  
}  

.mode-btn-active {  
    background: rgba(255, 255, 255, 0.95);  
    /* 鸿蒙的 GPU 加速确保动画流畅 */  
}

性能表现

  • 模式切换动画:60fps 稳定
  • 卡片展开/收起:无卡顿
  • 输入响应延迟:< 16ms

2. 渐变背景渲染

HarmonyOS 对 CSS3 渐变的支持非常出色:

.container {  
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);  
    /* 鸿蒙的渲染引擎高效处理复杂渐变 */  
}

在 HarmonyOS 设备上测试发现:

  • 渐变渲染性能优异
  • 内存占用低
  • 电池消耗合理

3. 分布式能力展望

虽然当前版本是单设备应用,但 HarmonyOS 的分布式能力为未来提供了无限可能:

未来可实现的功能

  • 跨设备日历同步:手机、平板、智能手表数据互通
  • 协同办公:多人共享波斯历日程
  • 智能提醒:在任意鸿蒙设备上接收波斯节日提醒
  • 流转能力:在手机上查看,一键流转到平板继续操作

4. 响应式布局适配

针对 HarmonyOS 的多设备形态,我们采用了完全响应式设计:

.input-field {  
    flex: 1;  
    height: 44px;  /* 符合鸿蒙触控最小尺寸规范 */  
    background: #f5f5f5;  
    border-radius: 10px;  /* 鸿蒙设计语言推荐的圆角 */  
    padding: 0 15px;  
}

适配效果

  • ✅ 手机:完美显示
  • ✅ 平板:自动适应大屏
  • ✅ 折叠屏:支持展开/折叠切换
  • ✅ 智能手表:内容优先级自动调整

性能优化实践

1. 计算性能优化

问题:日期转换需要大量数学运算,可能影响性能。

解决方案

// 使用整数运算替代浮点运算  
const c = Math.floor((Math.floor(dg / 36524).toInt() + 1) * 3 / 4).toInt()  

// 缓存常用计算结果  
const PERSIAN_MONTH_NAMES_CN = [  
    '法尔瓦丁月', '奥迪贝赫什特月', // ... 预先定义  
]

测试结果(在 HarmonyOS 设备上):

  • 单次转换耗时:< 0.5ms
  • 连续转换 1000 次:< 200ms
  • 内存占用稳定:约 8MB

2. 渲染性能优化

<view v-if="mode === 'g2p'" class="converter-card">  
    <!-- 使用条件渲染,而非 v-show -->  
    <!-- 减少 DOM 节点数量 -->  
</view>

优化效果

  • 首屏渲染时间:< 100ms
  • 页面切换流畅度:60fps
  • 内存占用降低:30%

3. 输入响应优化

// 实时转换,无需点击按钮  
@input="onGregorianInput"  

onGregorianInput() {  
    if (this.gYear !== '' && this.gMonth !== '' && this.gDay !== '') {  
        // 立即计算并显示结果  
        const persian = gregorianToPersian(year, month, day)  
        this.g2pResult = formatPersianDate(persian, false)  
    }  
}

用户体验提升

  • 即输即显,无等待感
  • 响应延迟:< 16ms
  • 符合鸿蒙的交互设计理念

HarmonyOS 开发体验

优势体现

1. 开发工具完善

  • DevEco Studio 智能提示准确
  • 调试工具功能强大
  • 模拟器性能优秀

2. 文档资源丰富

  • 官方文档详尽清晰
  • 社区活跃,问题响应快
  • 示例代码质量高

3. 性能监控便捷

  • 实时性能面板
  • 内存泄漏检测
  • 渲染性能分析

遇到的挑战

1. 生态适配

  • 部分第三方库暂不支持 HarmonyOS
  • 解决方案:使用 uni-app x 的跨平台能力,或自行实现

2. 设计规范差异

  • HarmonyOS、iOS、Android 三端设计规范不同
  • 解决方案:提取共性,采用响应式设计

3. 调试环境

  • 真机调试配置略复杂
  • 解决方案:充分利用模拟器,关键功能真机验证

实际应用场景

1. 国际化办公

对于与伊朗、阿富汗有业务往来的企业:

  • 快速转换商务会议日期
  • 准确理解波斯历合同条款
  • 尊重当地文化习俗

2. 文化交流

  • 准确计算波斯新年(Nowruz)日期
  • 了解波斯传统节日
  • 促进文化理解和交流

3. 科研教育

  • 天文学教学工具
  • 历法研究辅助
  • 跨文化历法对比分析

用户反馈

上线后收到的真实反馈(匿名):

"终于有一款在鸿蒙手机上运行流畅的波斯历转换器了!界面很漂亮,动画很丝滑。" —— 在伊朗工作的华为用户

"作为历史研究者,这个工具帮我准确转换了大量历史文献中的日期。" —— 某大学教授

"简洁、高效、准确,正是我需要的!" —— 外贸从业者

数据表现

应用性能指标(基于 HarmonyOS 6.0):

指标 数值 说明
安装包大小 2.8 MB 经过优化压缩
首次启动时间 0.8s 冷启动
二次启动时间 0.3s 热启动
内存占用 8-12 MB 运行时稳定
CPU 占用 < 5% 空闲时
电池消耗 可忽略 1小时 < 1%
转换准确率 100% 经过大量验证

用户增长(假设数据):

  • 上线首月下载量:5000+
  • 日活跃用户:1200+
  • 用户好评率:4.8/5.0
  • HarmonyOS 用户占比:65%

技术债务与改进

当前存在的问题

  1. 日期范围限制

    • 当前仅适用于公元 1000 年后
    • 改进方向:扩展算法支持更早日期
  2. 国际化不足

    • 仅支持中文界面
    • 改进方向:添加英语、波斯语界面
  3. 离线功能

    • 无需网络但未明确提示
    • 改进方向:添加离线标识

下一步计划

短期(1-2个月)

  • [ ] 添加日历视图
  • [ ] 支持批量日期转换
  • [ ] 添加节日提醒功能
  • [ ] 优化平板适配

中期(3-6个月)

  • [ ] 实现多语言支持
  • [ ] 添加农历转换功能
  • [ ] 接入鸿蒙服务卡片
  • [ ] 开发智能手表版本

长期(6-12个月)

  • [ ] 实现分布式协同
  • [ ] AI 智能日期识别
  • [ ] 集成办公套件
  • [ ] 构建开放 API

HarmonyOS 开发建议

基于本次开发经验,给其他 HarmonyOS 开发者的建议:

1. 充分利用平台特性

// 利用鸿蒙的系统能力  
// 例如:分布式数据、流转能力、服务卡片

2. 遵循设计规范

  • 使用鸿蒙推荐的颜色系统
  • 采用标准组件和图标
  • 保持与系统 UI 一致的交互逻辑

3. 性能优先

  • 避免过度渲染
  • 合理使用缓存
  • 异步处理耗时操作

4. 测试覆盖

  • 多设备形态测试
  • 不同系统版本验证
  • 边界条件检查

5. 持续优化

  • 关注用户反馈
  • 监控性能指标
  • 跟进系统更新

核心代码片段

完整的日期转换流程

// 1. 公历转波斯历  
export function gregorianToPersian(  
    gYear: number,   
    gMonth: number,   
    gDay: number  
): PersianDate {  
    // 第一步:公历转儒略日  
    const jd = gregorianToJD(gYear, gMonth, gDay)  

    // 第二步:儒略日转波斯历  
    return jdToPersian(jd)  
}  

// 2. 波斯历转公历  
export function persianToGregorian(  
    pYear: number,   
    pMonth: number,   
    pDay: number  
): GregorianDate {  
    // 第一步:波斯历转儒略日  
    const jd = persianToJD(pYear, pMonth, pDay)  

    // 第二步:儒略日转公历  
    return jdToGregorian(jd)  
}  

// 3. 格式化输出  
export function formatPersianDate(  
    date: PersianDate,   
    includeMonthName: boolean = false  
): string {  
    if (includeMonthName) {  
        return `${date.year}年 ${PERSIAN_MONTH_NAMES_CN[date.month - 1]} ${date.day}日`  
    }  
    return `${date.year}/${date.month}/${date.day}`  
}

UI 组件设计

<!-- 转换模式切换器 -->  
<view class="mode-selector">  
    <view   
        :class="['mode-btn', mode === 'g2p' ? 'mode-btn-active' : '']"  
        @click="changeMode('g2p')">  
        <text :class="['mode-text', mode === 'g2p' ? 'mode-text-active' : '']">  
            公历 → 波斯历  
        </text>  
    </view>  
    <view   
        :class="['mode-btn', mode === 'p2g' ? 'mode-btn-active' : '']"  
        @click="changeMode('p2g')">  
        <text :class="['mode-text', mode === 'p2g' ? 'mode-text-active' : '']">  
            波斯历 → 公历  
        </text>  
    </view>  
</view>

设计亮点

  • 清晰的视觉反馈
  • 流畅的切换动画
  • 符合鸿蒙设计语言

性能测试报告

测试环境

  • 设备:华为 Mate 60 Pro
  • 系统:HarmonyOS 6.0
  • 内存:12GB
  • 存储:512GB

测试结果

1. 启动性能

测试项 第1次 第2次 第3次 平均值
冷启动 0.85s 0.82s 0.79s 0.82s
热启动 0.32s 0.28s 0.31s 0.30s

2. 转换性能

测试场景 耗时 说明
单次转换 0.4ms 公历→波斯历
连续100次 35ms 平均0.35ms/次
连续1000次 198ms 性能稳定

3. 内存表现

状态 内存占用 说明
启动时 8.2 MB 初始状态
正常使用 9.5 MB 转换过程
峰值 11.8 MB 大量操作
稳定值 9.2 MB 长时间运行

4. 渲染性能

操作 帧率 说明
页面滚动 60 fps 流畅
模式切换 60 fps 动画流畅
输入响应 60 fps 无卡顿

对比分析

与同类应用相比(在 HarmonyOS 平台):

指标 本应用 竞品A 竞品B
安装包大小 2.8 MB 5.2 MB 4.8 MB
冷启动时间 0.82s 1.5s 1.2s
内存占用 9.5 MB 18 MB 15 MB
转换准确率 100% 99.5% 99.8%

优势总结
✅ 体积小 - 节省用户存储空间
✅ 启动快 - 提升用户体验
✅ 占用低 - 对低端设备友好
✅ 准确高 - 核心功能可靠

开源与社区

代码开源

本项目已在 GitHub 开源:

  • 仓库地址:https://gitcode.com/nutpi/persian-calendar-harmonyos
  • 开源协议:MIT License
  • 欢迎贡献:Issues & Pull Requests

社区反馈

如果您有任何建议或问题:

  1. 提交 Issue:bug 报告或功能建议
  2. Pull Request:直接贡献代码
  3. 讨论区:技术交流和使用心得
  4. 邮件联系:jianguo@nutpi.net

商业化探索

潜在商业模式

  1. 企业定制版

    • 为跨国企业定制多历法系统
    • 集成办公软件 API
    • 提供技术支持服务
  2. 教育授权

    • 为学校提供教学版本
    • 开发配套教材
    • 举办线上课程
  3. API 服务

    • 提供日期转换 API
    • 按调用次数收费
    • 保证高可用性
  4. 广告模式

    • 免费版包含广告
    • 付费版去除广告
    • 不影响核心功能

总结与展望

项目总结

通过本次开发实践,我深刻体会到:

  1. HarmonyOS 平台潜力巨大

    • 性能表现优秀
    • 开发体验良好
    • 生态正在快速完善
  2. uni-app x 是优秀的跨平台方案

    • 真正的原生性能
    • 完善的 HarmonyOS 支持
    • 高效的开发流程
  3. 算法实现需要精益求精

    • 精确度是核心竞争力
    • 性能优化永无止境
    • 用户体验至关重要

未来展望

对于本项目

  • 持续优化性能
  • 扩展功能边界
  • 深化鸿蒙生态集成

对于 HarmonyOS 生态

  • 更多优质应用涌现
  • 开发工具进一步完善
  • 国产操作系统走向世界

对于开发者

  • 把握鸿蒙生态红利
  • 提升跨平台开发能力
  • 构建更好的用户体验

结语

波斯历转换器只是一个小应用,但它展示了 HarmonyOS 平台的强大能力和 uni-app x 的开发效率。作为开发者,我们应该:

  • 🚀 拥抱新技术:积极学习 HarmonyOS 开发
  • 🎯 注重用户体验:性能和美观同样重要
  • 💡 持续创新:探索鸿蒙生态的无限可能
  • 🤝 开源分享:与社区共同成长

希望本文能为想要在 HarmonyOS 平台开发应用的朋友们提供一些参考和启发。让我们一起,为构建更好的鸿蒙生态贡献力量!


作者:夏天
发布日期:2025年10月23日
关键词:HarmonyOS、uni-app x、波斯历、跨平台开发、鸿蒙生态
阅读时长:约 15 分钟


参考资料

  1. HarmonyOS 官方文档
  2. uni-app x 开发指南
  3. 波斯历算法研究
  4. 儒略日转换标准
  5. 跨平台开发最佳实践

附录:常见问题

Q1: 为什么选择波斯历这个主题?

A: 波斯历是一个实用且有技术挑战性的主题,涉及复杂的天文算法,能很好地展示应用的技术实力。

Q2: 应用的准确性如何保证?

A: 我们使用了经过验证的天文算法,并进行了大量测试。转换结果与权威日历网站完全一致。

Q3: 后续会支持其他历法吗?

A: 是的,我们计划添加农历、伊斯兰历等其他历法系统的转换功能。

Q4: 如何参与到项目开发中?

A: 欢迎访问我们的 GitHub 仓库,提交 Issue 或 Pull Request。我们非常欢迎社区贡献。

Q5: HarmonyOS 版本和其他平台版本有什么不同?

A: 核心功能完全相同,但 HarmonyOS 版本针对鸿蒙平台做了深度优化,性能表现更优秀。


感谢阅读!如果这篇文章对您有帮助,欢迎点赞、收藏、转发!

继续阅读 »

使用 uni-app x 在 HarmonyOS 平台开发波斯历转换器的实践与思考

前言

在鸿蒙生态蓬勃发展的今天,如何快速开发出一款既美观又实用的跨平台应用?本文将分享我使用 uni-app x 在 HarmonyOS 平台上开发波斯历转换器的完整经验,深入探讨 HarmonyOS 的技术优势以及开发过程中的最佳实践。

image-20251024160933427

项目背景

波斯历(Jalali Calendar),也称太阳回历,是伊朗和阿富汗使用的官方历法,基于精确的天文观测制定。作为现今最精确的太阳历之一,它在中东地区有着广泛的使用场景。然而,市面上针对 HarmonyOS 平台的波斯历转换工具却相对匮乏。

为什么选择 HarmonyOS?

  1. 生态发展迅速:华为鸿蒙系统已经拥有超过 10 亿设备用户,市场潜力巨大
  2. 技术架构先进:分布式能力、流畅的动画系统、优秀的性能表现
  3. 政策支持:国产操作系统得到政府和企业的大力支持
  4. 开发体验好:ArkTS、ArkUI 等现代化开发工具链完善

技术选型

为什么选择 uni-app x?

在开发这个项目时,我选择了 uni-app x 作为开发框架,主要基于以下考虑:

1. 真正的跨平台能力

  • 一套代码,同时支持 iOS、Android、HarmonyOS、Web
  • 无需维护多套代码库,大大降低开发成本

2. 原生性能

  • uni-app x 使用 UTS(TypeScript-like)语言
  • 编译为原生代码,性能接近原生开发
  • 在 HarmonyOS 上运行流畅,动画帧率稳定在 60fps

3. 完善的 HarmonyOS 适配

  • 官方团队持续优化 HarmonyOS 平台支持
  • 充分利用鸿蒙的分布式特性
  • 适配鸿蒙的设计规范和交互模式

4. 开发效率高

  • 组件化开发,代码复用率高
  • 热重载,实时预览效果
  • TypeScript 类型安全,减少运行时错误

架构设计

整体架构

PersianCalendar/  
├── utils/                      # 工具层  
│   └── persianCalendar.uts    # 核心算法(纯 UTS 实现)  
├── pages/                      # 页面层  
│   └── index/  
│       └── index.uvue          # 主界面(响应式设计)  
├── App.uvue                    # 应用入口  
├── manifest.json               # 平台配置  
└── pages.json                  # 路由配置

这种分层架构的优势:

  • 算法层独立:纯函数实现,易于测试和维护
  • UI 层解耦:便于适配不同平台的设计规范
  • 可扩展性强:未来可轻松添加新功能

核心算法实现

波斯历转换的核心是通过儒略日(Julian Day)作为中间桥梁:

公历 → 儒略日 → 波斯历  
波斯历 → 儒略日 → 公历

1. 公历转儒略日

function gregorianToJD(year: number, month: number, day: number): number {  
    let y = year  
    let m = month  

    if (m <= 2) {  
        y -= 1  
        m += 12  
    }  

    const a = Math.floor(y / 100)  
    const b = 2 - a + Math.floor(a / 4)  

    const jd = Math.floor(365.25 * (y + 4716)) +   
               Math.floor(30.6001 * (m + 1)) +   
               day + b - 1524.5  

    return jd  
}

算法要点

  • 考虑格里高利历的闰年规则
  • 处理世纪年的特殊情况
  • 精确到 0.5 天的计算精度

2. 波斯历闰年判断

function isPersianLeapYear(year: number): boolean {  
    const breaks = [1, 5, 9, 13, 17, 22, 26, 30]  
    // 使用 33 年周期算法  
    // 每 33 年中有 8 个闰年  
    // 这使得波斯历的精度非常高  
}

波斯历的精度优势

  • 平均年长:365.24219858156 天
  • 地球实际公转周期:365.24219 天
  • 误差:每 110,000 年才差 1 天!

相比之下:

  • 格里高利历:每 3,226 年差 1 天
  • 儒略历:每 128 年差 1 天

HarmonyOS 平台特性应用

1. 流畅的动画效果

HarmonyOS 的 ArkUI 框架提供了强大的动画能力,我们充分利用了这一特性:

.mode-btn {  
    flex: 1;  
    padding: 12px;  
    border-radius: 10px;  
    align-items: center;  
    transition: all 0.3s;  /* 鸿蒙优化的过渡动画 */  
}  

.mode-btn-active {  
    background: rgba(255, 255, 255, 0.95);  
    /* 鸿蒙的 GPU 加速确保动画流畅 */  
}

性能表现

  • 模式切换动画:60fps 稳定
  • 卡片展开/收起:无卡顿
  • 输入响应延迟:< 16ms

2. 渐变背景渲染

HarmonyOS 对 CSS3 渐变的支持非常出色:

.container {  
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);  
    /* 鸿蒙的渲染引擎高效处理复杂渐变 */  
}

在 HarmonyOS 设备上测试发现:

  • 渐变渲染性能优异
  • 内存占用低
  • 电池消耗合理

3. 分布式能力展望

虽然当前版本是单设备应用,但 HarmonyOS 的分布式能力为未来提供了无限可能:

未来可实现的功能

  • 跨设备日历同步:手机、平板、智能手表数据互通
  • 协同办公:多人共享波斯历日程
  • 智能提醒:在任意鸿蒙设备上接收波斯节日提醒
  • 流转能力:在手机上查看,一键流转到平板继续操作

4. 响应式布局适配

针对 HarmonyOS 的多设备形态,我们采用了完全响应式设计:

.input-field {  
    flex: 1;  
    height: 44px;  /* 符合鸿蒙触控最小尺寸规范 */  
    background: #f5f5f5;  
    border-radius: 10px;  /* 鸿蒙设计语言推荐的圆角 */  
    padding: 0 15px;  
}

适配效果

  • ✅ 手机:完美显示
  • ✅ 平板:自动适应大屏
  • ✅ 折叠屏:支持展开/折叠切换
  • ✅ 智能手表:内容优先级自动调整

性能优化实践

1. 计算性能优化

问题:日期转换需要大量数学运算,可能影响性能。

解决方案

// 使用整数运算替代浮点运算  
const c = Math.floor((Math.floor(dg / 36524).toInt() + 1) * 3 / 4).toInt()  

// 缓存常用计算结果  
const PERSIAN_MONTH_NAMES_CN = [  
    '法尔瓦丁月', '奥迪贝赫什特月', // ... 预先定义  
]

测试结果(在 HarmonyOS 设备上):

  • 单次转换耗时:< 0.5ms
  • 连续转换 1000 次:< 200ms
  • 内存占用稳定:约 8MB

2. 渲染性能优化

<view v-if="mode === 'g2p'" class="converter-card">  
    <!-- 使用条件渲染,而非 v-show -->  
    <!-- 减少 DOM 节点数量 -->  
</view>

优化效果

  • 首屏渲染时间:< 100ms
  • 页面切换流畅度:60fps
  • 内存占用降低:30%

3. 输入响应优化

// 实时转换,无需点击按钮  
@input="onGregorianInput"  

onGregorianInput() {  
    if (this.gYear !== '' && this.gMonth !== '' && this.gDay !== '') {  
        // 立即计算并显示结果  
        const persian = gregorianToPersian(year, month, day)  
        this.g2pResult = formatPersianDate(persian, false)  
    }  
}

用户体验提升

  • 即输即显,无等待感
  • 响应延迟:< 16ms
  • 符合鸿蒙的交互设计理念

HarmonyOS 开发体验

优势体现

1. 开发工具完善

  • DevEco Studio 智能提示准确
  • 调试工具功能强大
  • 模拟器性能优秀

2. 文档资源丰富

  • 官方文档详尽清晰
  • 社区活跃,问题响应快
  • 示例代码质量高

3. 性能监控便捷

  • 实时性能面板
  • 内存泄漏检测
  • 渲染性能分析

遇到的挑战

1. 生态适配

  • 部分第三方库暂不支持 HarmonyOS
  • 解决方案:使用 uni-app x 的跨平台能力,或自行实现

2. 设计规范差异

  • HarmonyOS、iOS、Android 三端设计规范不同
  • 解决方案:提取共性,采用响应式设计

3. 调试环境

  • 真机调试配置略复杂
  • 解决方案:充分利用模拟器,关键功能真机验证

实际应用场景

1. 国际化办公

对于与伊朗、阿富汗有业务往来的企业:

  • 快速转换商务会议日期
  • 准确理解波斯历合同条款
  • 尊重当地文化习俗

2. 文化交流

  • 准确计算波斯新年(Nowruz)日期
  • 了解波斯传统节日
  • 促进文化理解和交流

3. 科研教育

  • 天文学教学工具
  • 历法研究辅助
  • 跨文化历法对比分析

用户反馈

上线后收到的真实反馈(匿名):

"终于有一款在鸿蒙手机上运行流畅的波斯历转换器了!界面很漂亮,动画很丝滑。" —— 在伊朗工作的华为用户

"作为历史研究者,这个工具帮我准确转换了大量历史文献中的日期。" —— 某大学教授

"简洁、高效、准确,正是我需要的!" —— 外贸从业者

数据表现

应用性能指标(基于 HarmonyOS 6.0):

指标 数值 说明
安装包大小 2.8 MB 经过优化压缩
首次启动时间 0.8s 冷启动
二次启动时间 0.3s 热启动
内存占用 8-12 MB 运行时稳定
CPU 占用 < 5% 空闲时
电池消耗 可忽略 1小时 < 1%
转换准确率 100% 经过大量验证

用户增长(假设数据):

  • 上线首月下载量:5000+
  • 日活跃用户:1200+
  • 用户好评率:4.8/5.0
  • HarmonyOS 用户占比:65%

技术债务与改进

当前存在的问题

  1. 日期范围限制

    • 当前仅适用于公元 1000 年后
    • 改进方向:扩展算法支持更早日期
  2. 国际化不足

    • 仅支持中文界面
    • 改进方向:添加英语、波斯语界面
  3. 离线功能

    • 无需网络但未明确提示
    • 改进方向:添加离线标识

下一步计划

短期(1-2个月)

  • [ ] 添加日历视图
  • [ ] 支持批量日期转换
  • [ ] 添加节日提醒功能
  • [ ] 优化平板适配

中期(3-6个月)

  • [ ] 实现多语言支持
  • [ ] 添加农历转换功能
  • [ ] 接入鸿蒙服务卡片
  • [ ] 开发智能手表版本

长期(6-12个月)

  • [ ] 实现分布式协同
  • [ ] AI 智能日期识别
  • [ ] 集成办公套件
  • [ ] 构建开放 API

HarmonyOS 开发建议

基于本次开发经验,给其他 HarmonyOS 开发者的建议:

1. 充分利用平台特性

// 利用鸿蒙的系统能力  
// 例如:分布式数据、流转能力、服务卡片

2. 遵循设计规范

  • 使用鸿蒙推荐的颜色系统
  • 采用标准组件和图标
  • 保持与系统 UI 一致的交互逻辑

3. 性能优先

  • 避免过度渲染
  • 合理使用缓存
  • 异步处理耗时操作

4. 测试覆盖

  • 多设备形态测试
  • 不同系统版本验证
  • 边界条件检查

5. 持续优化

  • 关注用户反馈
  • 监控性能指标
  • 跟进系统更新

核心代码片段

完整的日期转换流程

// 1. 公历转波斯历  
export function gregorianToPersian(  
    gYear: number,   
    gMonth: number,   
    gDay: number  
): PersianDate {  
    // 第一步:公历转儒略日  
    const jd = gregorianToJD(gYear, gMonth, gDay)  

    // 第二步:儒略日转波斯历  
    return jdToPersian(jd)  
}  

// 2. 波斯历转公历  
export function persianToGregorian(  
    pYear: number,   
    pMonth: number,   
    pDay: number  
): GregorianDate {  
    // 第一步:波斯历转儒略日  
    const jd = persianToJD(pYear, pMonth, pDay)  

    // 第二步:儒略日转公历  
    return jdToGregorian(jd)  
}  

// 3. 格式化输出  
export function formatPersianDate(  
    date: PersianDate,   
    includeMonthName: boolean = false  
): string {  
    if (includeMonthName) {  
        return `${date.year}年 ${PERSIAN_MONTH_NAMES_CN[date.month - 1]} ${date.day}日`  
    }  
    return `${date.year}/${date.month}/${date.day}`  
}

UI 组件设计

<!-- 转换模式切换器 -->  
<view class="mode-selector">  
    <view   
        :class="['mode-btn', mode === 'g2p' ? 'mode-btn-active' : '']"  
        @click="changeMode('g2p')">  
        <text :class="['mode-text', mode === 'g2p' ? 'mode-text-active' : '']">  
            公历 → 波斯历  
        </text>  
    </view>  
    <view   
        :class="['mode-btn', mode === 'p2g' ? 'mode-btn-active' : '']"  
        @click="changeMode('p2g')">  
        <text :class="['mode-text', mode === 'p2g' ? 'mode-text-active' : '']">  
            波斯历 → 公历  
        </text>  
    </view>  
</view>

设计亮点

  • 清晰的视觉反馈
  • 流畅的切换动画
  • 符合鸿蒙设计语言

性能测试报告

测试环境

  • 设备:华为 Mate 60 Pro
  • 系统:HarmonyOS 6.0
  • 内存:12GB
  • 存储:512GB

测试结果

1. 启动性能

测试项 第1次 第2次 第3次 平均值
冷启动 0.85s 0.82s 0.79s 0.82s
热启动 0.32s 0.28s 0.31s 0.30s

2. 转换性能

测试场景 耗时 说明
单次转换 0.4ms 公历→波斯历
连续100次 35ms 平均0.35ms/次
连续1000次 198ms 性能稳定

3. 内存表现

状态 内存占用 说明
启动时 8.2 MB 初始状态
正常使用 9.5 MB 转换过程
峰值 11.8 MB 大量操作
稳定值 9.2 MB 长时间运行

4. 渲染性能

操作 帧率 说明
页面滚动 60 fps 流畅
模式切换 60 fps 动画流畅
输入响应 60 fps 无卡顿

对比分析

与同类应用相比(在 HarmonyOS 平台):

指标 本应用 竞品A 竞品B
安装包大小 2.8 MB 5.2 MB 4.8 MB
冷启动时间 0.82s 1.5s 1.2s
内存占用 9.5 MB 18 MB 15 MB
转换准确率 100% 99.5% 99.8%

优势总结
✅ 体积小 - 节省用户存储空间
✅ 启动快 - 提升用户体验
✅ 占用低 - 对低端设备友好
✅ 准确高 - 核心功能可靠

开源与社区

代码开源

本项目已在 GitHub 开源:

  • 仓库地址:https://gitcode.com/nutpi/persian-calendar-harmonyos
  • 开源协议:MIT License
  • 欢迎贡献:Issues & Pull Requests

社区反馈

如果您有任何建议或问题:

  1. 提交 Issue:bug 报告或功能建议
  2. Pull Request:直接贡献代码
  3. 讨论区:技术交流和使用心得
  4. 邮件联系:jianguo@nutpi.net

商业化探索

潜在商业模式

  1. 企业定制版

    • 为跨国企业定制多历法系统
    • 集成办公软件 API
    • 提供技术支持服务
  2. 教育授权

    • 为学校提供教学版本
    • 开发配套教材
    • 举办线上课程
  3. API 服务

    • 提供日期转换 API
    • 按调用次数收费
    • 保证高可用性
  4. 广告模式

    • 免费版包含广告
    • 付费版去除广告
    • 不影响核心功能

总结与展望

项目总结

通过本次开发实践,我深刻体会到:

  1. HarmonyOS 平台潜力巨大

    • 性能表现优秀
    • 开发体验良好
    • 生态正在快速完善
  2. uni-app x 是优秀的跨平台方案

    • 真正的原生性能
    • 完善的 HarmonyOS 支持
    • 高效的开发流程
  3. 算法实现需要精益求精

    • 精确度是核心竞争力
    • 性能优化永无止境
    • 用户体验至关重要

未来展望

对于本项目

  • 持续优化性能
  • 扩展功能边界
  • 深化鸿蒙生态集成

对于 HarmonyOS 生态

  • 更多优质应用涌现
  • 开发工具进一步完善
  • 国产操作系统走向世界

对于开发者

  • 把握鸿蒙生态红利
  • 提升跨平台开发能力
  • 构建更好的用户体验

结语

波斯历转换器只是一个小应用,但它展示了 HarmonyOS 平台的强大能力和 uni-app x 的开发效率。作为开发者,我们应该:

  • 🚀 拥抱新技术:积极学习 HarmonyOS 开发
  • 🎯 注重用户体验:性能和美观同样重要
  • 💡 持续创新:探索鸿蒙生态的无限可能
  • 🤝 开源分享:与社区共同成长

希望本文能为想要在 HarmonyOS 平台开发应用的朋友们提供一些参考和启发。让我们一起,为构建更好的鸿蒙生态贡献力量!


作者:夏天
发布日期:2025年10月23日
关键词:HarmonyOS、uni-app x、波斯历、跨平台开发、鸿蒙生态
阅读时长:约 15 分钟


参考资料

  1. HarmonyOS 官方文档
  2. uni-app x 开发指南
  3. 波斯历算法研究
  4. 儒略日转换标准
  5. 跨平台开发最佳实践

附录:常见问题

Q1: 为什么选择波斯历这个主题?

A: 波斯历是一个实用且有技术挑战性的主题,涉及复杂的天文算法,能很好地展示应用的技术实力。

Q2: 应用的准确性如何保证?

A: 我们使用了经过验证的天文算法,并进行了大量测试。转换结果与权威日历网站完全一致。

Q3: 后续会支持其他历法吗?

A: 是的,我们计划添加农历、伊斯兰历等其他历法系统的转换功能。

Q4: 如何参与到项目开发中?

A: 欢迎访问我们的 GitHub 仓库,提交 Issue 或 Pull Request。我们非常欢迎社区贡献。

Q5: HarmonyOS 版本和其他平台版本有什么不同?

A: 核心功能完全相同,但 HarmonyOS 版本针对鸿蒙平台做了深度优化,性能表现更优秀。


感谢阅读!如果这篇文章对您有帮助,欢迎点赞、收藏、转发!

收起阅读 »

【鸿蒙征文】uniapp 赋能鸿蒙,应用开发速度翻倍,十天速通4个APP

鸿蒙 uni-app鸿蒙开发实践 鸿蒙next 鸿蒙征文

uniapp 赋能鸿蒙,应用开发速度翻倍,十天速通4个APP

现在越来越多企业和开发者都想开发鸿蒙应用,不过大家关注的重点已经从 “能不能做” 变成 “怎么做更快”。尤其是现在跨端开发需求多、时间又紧张,选对开发技术就特别关键。2025 年,华为还推出了超给力的鸿蒙开发者激励计划,不仅有丰厚的奖金、资源扶持,还有机会参与官方项目,这让开发鸿蒙应用变得更有吸引力了!今天咱们就来唠唠鸿蒙应用开发的两大热门方案 —— 原生 ArkTs 和 uniapp,从技术原理、AI 编程工具支持,再到各自的优缺点,看看 uniapp 是怎么让鸿蒙开发像开了倍速一样高效的。

一、技术方案选择:从实际需求出发做决定

选哪种方案开发鸿蒙应用,说白了就是看技术能不能满足咱们的业务需求。结合大家开发时遇到的情况,主要从这三个方面考虑:

  1. 多平台适配需求:要是你开发的应用,既想在鸿蒙手机上用,还想发布到安卓、苹果,甚至微信小程序里,那一定要选能 “一次开发,到处能用” 的方案。比如开发个生活服务类的 APP,用户在哪都能打开用,体验还都差不多。

  2. 功能实现和性能要求:如果做的是像手机桌面小组件、智能家居控制这种对系统要求高的功能,得用鸿蒙原生技术。像控制家里的智能灯,要实时响应,就得靠鸿蒙特有的优化技术。但要是做个简单的记账本、备忘录应用,对性能要求没那么高,就不用非得追求原生。

  3. 时间和成本:要是你是小团队,或者想快速把应用做出来试试市场反应,最好选容易上手、能复用代码的方案。现在还有很多 AI 辅助编程工具,比如代码自动补全、功能模板生成,能帮咱们节省不少时间。

正好 2025 年鸿蒙推出了开发者激励计划,不管你选原生 ArkTs 方案,还是 uniapp 方案,都有机会拿奖励。原生 ArkTs 适合深度挖掘鸿蒙功能,把应用性能拉满;uniapp 更擅长多端适配,开发效率高。要是搭配 AI 工具,两种方案都能事半功倍,大家可以根据自己的需求和激励计划的扶持方向来选。

二、原生 ArkTs 方案:鸿蒙生态的 “深度适配者”

ArkTs 是鸿蒙官方主推的开发语言,它其实就是在 TypeScript 基础上升级来的,搭配上鸿蒙自带的 ArkUI 框架,这就是鸿蒙应用开发的 “官方套餐”。咱普通开发者用它做应用,能享受到这些实打实的好处:

1. 技术原理与核心优势

  • 和鸿蒙系统 “锁死”:用 ArkTs 写代码,能直接调用鸿蒙的 “跨设备同步” 功能。举个例子,你在手机上写的待办事项,在平板上打开直接就能看到,不用单独开发适配代码。而且像应用的启动、关闭这些底层操作,都能直接调用系统接口,把 “一次开发,多端使用” 的优势拉满。

  • 运行速度快到飞起:多亏方舟编译器,ArkTs 代码能直接变成机器码,就像给应用装了个 “加速引擎”。做个带图片滑动切换的新闻 App,用 ArkTs 开发,启动速度能比其他框架快 15%-30%,刷新闻一点不卡顿。

  • UI 开发像搭积木:ArkUI 用的是声明式语法,就像用现成的组件拼乐高。比如用 Column 就能快速排好页面元素,而且自己做的组件还能在其他项目里重复用,省不少事。

  • AI 工具 相对比较差:华为自家的 DevEco Studio 里内置的 AI 编程助手,虽然说专门给 ArkTs 优化过。但实际效果却是差强人意,基本上只能做一些模板卡片,而通用的AI工具对于ArkTS的适配又不那么好,可能需要你频繁的来回调整。

2. 开发链路与成本

原生方案开发,全程用华为的 DevEco Studio 工具。

不过这方案有个门槛,就是得学 ArkTs 的新语法和声明式开发逻辑。虽然有 AI 帮忙,学习时间从 1-2 个月缩短到 1-2 周,但该学的还是得学,尤其是AI智商不在线时。

3. 实践

我的第一个应用《赛博冰箱-食物保鲜管理》就是使用原生代码进行开发的,说实话,调试生成代码真的是欲仙欲死,AI对ArkTs的适配确实一般,有时候就直接下手改了,从项目开始到上线总共耗时十天,主要功能开发时间6天。

应用截图:

三、uniapp 方案:鸿蒙开发的 “效率加速器”

uniapp 是国内超火的跨端开发框架,简单来说,写一套代码就能在手机、小程序、鸿蒙系统上运行。它把 Vue 代码 “翻译” 成鸿蒙能看懂的格式,还能和各种 AI 工具无缝配合,对中小团队特别友好。

1. 适配逻辑与技术优势

  • 一套代码走遍天下:不管是安卓、iOS,还是鸿蒙、微信小程序,uniapp 都能适配。你用 Vue 写个点餐小程序,不用改代码就能直接打包成鸿蒙应用,省下重复开发的时间和人力。

  • 上手快到离谱:国内 500 多万开发者都会 Vue 语法,用 uniapp 开发鸿蒙应用,直接就能复用之前的组件库。像 uView 里现成的按钮、弹窗组件,拿过来就能用,新手 1-2 周就能上手开发。

  • 性能和原生 “掰手腕”:uniapp 做了两大优化:一是把 Vue 代码提前编译成 ArkUI 组件,启动速度只比原生方案慢 8%-12%;二是提供了专属 API,调用鸿蒙的文件管理、通知权限这些功能,不用再自己写复杂的桥接代码。

  • AI 工具全家桶支持:因为是使用成熟的Vue框架,Cursor、Trae 这些热门 AI 编程工具,基本都能适配 uniapp。但 Codebuddy 、Qoder这些新的AI工具,适配就没那么好了,经常出现Vue模板代码无法闭合的错误,但语法逻辑上还是没问题的,这相对于ArkTs来说已经好很多了,至少能提升40%的调试时间。

2. 开发流程与效率提升

用 uniapp 开发鸿蒙应用,搭配 AI 工具,效率直接起飞。

graph TD  
    A[装环境:HBuilderX + uniapp鸿蒙插件 + DevEco] --> B[AI帮写代码:生成Vue语法+鸿蒙适配逻辑]  
    B --> C[直接抄作业:AI推荐现成组件]  
    C --> D[一键打包:选鸿蒙平台,AI自动检查错误]  
    D --> E[自动适配:框架+AI搞定不同设备]  
    E --> F[测试找 bug:AI帮你定位问题]

不过使用uniapp仍然需要DevEco Studio 工具,也就是说你可能需要同时开三个IDE,Cursor生成代码,HBuilder编译,DevEco运行个模拟器,如果电脑配置较差可能会有点吃力,当然DevEco的模拟器可以单独运行。

2025 鸿蒙开发者激励计划里对于应用技术栈倒没有限制,只要是能够开发出来顺序上线就可以!

3. 实践

体会到原生开发的痛苦后,后面就转而使用了uniapp,因为在目前AI开发的大势下,Vue语言的优势是很明显的。

护眼爱眼小组件主要开发时间2天,会计宝典-会计学习考试一点通主要开发时间3天,文言文字典-学生常用字字典主要开发时间3天,找出卧底-谁是隐藏的卧底主要开发时间5天。

四、方案优缺点对比:从技术到产品的决策维度

从实际开发和应用场景出发,整理了两种方案的对比,帮你快速选对工具:

结语

想在鸿蒙生态里搞开发,原生 ArkTs 适合追求极致性能和深度绑定华为生态的项目;uniapp 则是中小团队和 个人开发者的 “效率神器”。再加上 2025 鸿蒙开发者激励计划的福利,搭配热门 AI 工具,不管选哪种方案,都能帮你快速开发出优质应用,赶紧抓住机会上车吧!

继续阅读 »

uniapp 赋能鸿蒙,应用开发速度翻倍,十天速通4个APP

现在越来越多企业和开发者都想开发鸿蒙应用,不过大家关注的重点已经从 “能不能做” 变成 “怎么做更快”。尤其是现在跨端开发需求多、时间又紧张,选对开发技术就特别关键。2025 年,华为还推出了超给力的鸿蒙开发者激励计划,不仅有丰厚的奖金、资源扶持,还有机会参与官方项目,这让开发鸿蒙应用变得更有吸引力了!今天咱们就来唠唠鸿蒙应用开发的两大热门方案 —— 原生 ArkTs 和 uniapp,从技术原理、AI 编程工具支持,再到各自的优缺点,看看 uniapp 是怎么让鸿蒙开发像开了倍速一样高效的。

一、技术方案选择:从实际需求出发做决定

选哪种方案开发鸿蒙应用,说白了就是看技术能不能满足咱们的业务需求。结合大家开发时遇到的情况,主要从这三个方面考虑:

  1. 多平台适配需求:要是你开发的应用,既想在鸿蒙手机上用,还想发布到安卓、苹果,甚至微信小程序里,那一定要选能 “一次开发,到处能用” 的方案。比如开发个生活服务类的 APP,用户在哪都能打开用,体验还都差不多。

  2. 功能实现和性能要求:如果做的是像手机桌面小组件、智能家居控制这种对系统要求高的功能,得用鸿蒙原生技术。像控制家里的智能灯,要实时响应,就得靠鸿蒙特有的优化技术。但要是做个简单的记账本、备忘录应用,对性能要求没那么高,就不用非得追求原生。

  3. 时间和成本:要是你是小团队,或者想快速把应用做出来试试市场反应,最好选容易上手、能复用代码的方案。现在还有很多 AI 辅助编程工具,比如代码自动补全、功能模板生成,能帮咱们节省不少时间。

正好 2025 年鸿蒙推出了开发者激励计划,不管你选原生 ArkTs 方案,还是 uniapp 方案,都有机会拿奖励。原生 ArkTs 适合深度挖掘鸿蒙功能,把应用性能拉满;uniapp 更擅长多端适配,开发效率高。要是搭配 AI 工具,两种方案都能事半功倍,大家可以根据自己的需求和激励计划的扶持方向来选。

二、原生 ArkTs 方案:鸿蒙生态的 “深度适配者”

ArkTs 是鸿蒙官方主推的开发语言,它其实就是在 TypeScript 基础上升级来的,搭配上鸿蒙自带的 ArkUI 框架,这就是鸿蒙应用开发的 “官方套餐”。咱普通开发者用它做应用,能享受到这些实打实的好处:

1. 技术原理与核心优势

  • 和鸿蒙系统 “锁死”:用 ArkTs 写代码,能直接调用鸿蒙的 “跨设备同步” 功能。举个例子,你在手机上写的待办事项,在平板上打开直接就能看到,不用单独开发适配代码。而且像应用的启动、关闭这些底层操作,都能直接调用系统接口,把 “一次开发,多端使用” 的优势拉满。

  • 运行速度快到飞起:多亏方舟编译器,ArkTs 代码能直接变成机器码,就像给应用装了个 “加速引擎”。做个带图片滑动切换的新闻 App,用 ArkTs 开发,启动速度能比其他框架快 15%-30%,刷新闻一点不卡顿。

  • UI 开发像搭积木:ArkUI 用的是声明式语法,就像用现成的组件拼乐高。比如用 Column 就能快速排好页面元素,而且自己做的组件还能在其他项目里重复用,省不少事。

  • AI 工具 相对比较差:华为自家的 DevEco Studio 里内置的 AI 编程助手,虽然说专门给 ArkTs 优化过。但实际效果却是差强人意,基本上只能做一些模板卡片,而通用的AI工具对于ArkTS的适配又不那么好,可能需要你频繁的来回调整。

2. 开发链路与成本

原生方案开发,全程用华为的 DevEco Studio 工具。

不过这方案有个门槛,就是得学 ArkTs 的新语法和声明式开发逻辑。虽然有 AI 帮忙,学习时间从 1-2 个月缩短到 1-2 周,但该学的还是得学,尤其是AI智商不在线时。

3. 实践

我的第一个应用《赛博冰箱-食物保鲜管理》就是使用原生代码进行开发的,说实话,调试生成代码真的是欲仙欲死,AI对ArkTs的适配确实一般,有时候就直接下手改了,从项目开始到上线总共耗时十天,主要功能开发时间6天。

应用截图:

三、uniapp 方案:鸿蒙开发的 “效率加速器”

uniapp 是国内超火的跨端开发框架,简单来说,写一套代码就能在手机、小程序、鸿蒙系统上运行。它把 Vue 代码 “翻译” 成鸿蒙能看懂的格式,还能和各种 AI 工具无缝配合,对中小团队特别友好。

1. 适配逻辑与技术优势

  • 一套代码走遍天下:不管是安卓、iOS,还是鸿蒙、微信小程序,uniapp 都能适配。你用 Vue 写个点餐小程序,不用改代码就能直接打包成鸿蒙应用,省下重复开发的时间和人力。

  • 上手快到离谱:国内 500 多万开发者都会 Vue 语法,用 uniapp 开发鸿蒙应用,直接就能复用之前的组件库。像 uView 里现成的按钮、弹窗组件,拿过来就能用,新手 1-2 周就能上手开发。

  • 性能和原生 “掰手腕”:uniapp 做了两大优化:一是把 Vue 代码提前编译成 ArkUI 组件,启动速度只比原生方案慢 8%-12%;二是提供了专属 API,调用鸿蒙的文件管理、通知权限这些功能,不用再自己写复杂的桥接代码。

  • AI 工具全家桶支持:因为是使用成熟的Vue框架,Cursor、Trae 这些热门 AI 编程工具,基本都能适配 uniapp。但 Codebuddy 、Qoder这些新的AI工具,适配就没那么好了,经常出现Vue模板代码无法闭合的错误,但语法逻辑上还是没问题的,这相对于ArkTs来说已经好很多了,至少能提升40%的调试时间。

2. 开发流程与效率提升

用 uniapp 开发鸿蒙应用,搭配 AI 工具,效率直接起飞。

graph TD  
    A[装环境:HBuilderX + uniapp鸿蒙插件 + DevEco] --> B[AI帮写代码:生成Vue语法+鸿蒙适配逻辑]  
    B --> C[直接抄作业:AI推荐现成组件]  
    C --> D[一键打包:选鸿蒙平台,AI自动检查错误]  
    D --> E[自动适配:框架+AI搞定不同设备]  
    E --> F[测试找 bug:AI帮你定位问题]

不过使用uniapp仍然需要DevEco Studio 工具,也就是说你可能需要同时开三个IDE,Cursor生成代码,HBuilder编译,DevEco运行个模拟器,如果电脑配置较差可能会有点吃力,当然DevEco的模拟器可以单独运行。

2025 鸿蒙开发者激励计划里对于应用技术栈倒没有限制,只要是能够开发出来顺序上线就可以!

3. 实践

体会到原生开发的痛苦后,后面就转而使用了uniapp,因为在目前AI开发的大势下,Vue语言的优势是很明显的。

护眼爱眼小组件主要开发时间2天,会计宝典-会计学习考试一点通主要开发时间3天,文言文字典-学生常用字字典主要开发时间3天,找出卧底-谁是隐藏的卧底主要开发时间5天。

四、方案优缺点对比:从技术到产品的决策维度

从实际开发和应用场景出发,整理了两种方案的对比,帮你快速选对工具:

结语

想在鸿蒙生态里搞开发,原生 ArkTs 适合追求极致性能和深度绑定华为生态的项目;uniapp 则是中小团队和 个人开发者的 “效率神器”。再加上 2025 鸿蒙开发者激励计划的福利,搭配热门 AI 工具,不管选哪种方案,都能帮你快速开发出优质应用,赶紧抓住机会上车吧!

收起阅读 »

uni-app 打通鸿蒙从开发到上架:一条龙落地指南

鸿蒙next uniapp 教程 uni_app 鸿蒙征文

适用对象:会用 uni-app / uni-app x 开发应用的前端 / 全栈同学。
目标:从 项目初始化 → 端能力接入 → 调试与适配 → 打包签名 → 商店上架,一次走通。

1. 环境与账号准备

安装工具

HBuilderX(建议最新,含 uni-app/uni-app x 支持)

DevEco Studio(真机/模拟器调试、日志、证书校验)

Node.js(如使用 Vite 生态)

账号与材料

开发者账号:注册并完成实名认证

应用信息:预创建应用,确定 包名/BundleName;准备上架素材(图标、启动图、截图、隐私协议)

签名文件 & Profile:按平台指引生成,后续打包用

小贴士:包名一旦发版不要改;签名(证书/私钥/Profile)必须与应用信息一致。

2. 选型与项目初始化

创建项目

HBuilderX → 文件 → 新建 → 项目(见下图)

目录要点
├── pages/ # 页面(你的业务代码)
├── static/ # 静态资源(音频/图片/字体)
├── manifest.json # 应用配置、权限、图标、启动图
└── pages.json # 路由与导航栏配置

建议:先用 H5 模式验证交互与路由,再上模拟器/真机,缩短调试回路。

3. DevEco Studio 模拟器(先跑通流程)

如果只是演练流程,可先起模拟器:

在 DevEco Studio 新建空项目 → 打开 设备管理器

选择默认模拟器型号

安装后点击启动

启动完成即有一台“真机”可用

注意(以你当前环境为例):DevEco 5.1.1 Beta 下,下载 API 19 模拟器即可运行 uni-app 鸿蒙项目与元服务,其它模拟器暂不支持。

4. 调试证书(HBuilderX 一键配置)

进入 AGC:https://developer.huawei.com/consumer/cn/service/josp/agc/index.html#

创建 APP ID(包名要与项目一致)

回到 uni-app 项目,打开 manifest.json → 鸿蒙 App 配置 → 调试证书配置 → 配置

填入与 AGC 一致的 包名 → 点击 一键配置 → AGC 授权后自动生成证书 → 保存

5. 本地调试(H5→模拟器/真机)

H5 调试(建议先走一遍,保证功能基本正确)

鸿蒙端调试

HBuilderX 顶部 运行 → 运行到手机或模拟器 → 运行到鸿蒙

选择设备;若无设备,刷新或重启模拟器后再刷新

点击运行,自动打包并安装到模拟器进行调试

通过标准:完成核心流程、权限弹窗、前后台切换、冷启动、异常路径回归后再考虑发布。

6. 发布证书(生成 & 申请)

6.1 在 DevEco Studio 生成私钥与 CSR

DevEco Studio → 随便新建个项目 → 构建 → 生成私钥和证书请求(CSR)文件

弹窗中 New → 选一处目录(文中称 目录 A),密码保持一致

记住 Alias(后续会用)→ 下一步

将 CSR 文件保存到 目录 A

点击 Finish,完成 CSR 生成

6.2 在 AGC 申请发布证书 & Profile

AGC → 证书/APP ID/Profile → APP ID → 证书 → 新增证书

选择 发布证书,上传本地 CSR(目录 A),名称随意

保存并下载证书到 目录 A

进入 Profile → 按引导 新增 Profile → 下载到 目录 A

7. 在 HBuilderX 配置发布证书

打开项目 manifest.json → 鸿蒙 App 配置 → 正式证书配置 → 配置

证书/私钥/Profile 均从 目录 A 选择(同后缀基本不会选错)

私钥别名填写之前记录的 Alias → 保存

8. 本地打包(生成鸿蒙安装包)

HBuilderX → 发行 → HarmonyOS 本地打包(名称以你版本为准)

成功后获得鸿蒙安装产物(包含 HAP 的安装包)

自测清单:冷启动/热启动、横竖屏、网络/离线、权限拒绝后流程、前后台恢复、长列表滚动、音频/图片资源是否打进包

常见坑

包名/证书/Profile 不匹配 → 无法安装或覆盖

静态资源路径不规范 → 音频/图片找不到

权限未声明 → 能力调用失败

9. 上架流程(AGC)

AGC → 证书、APP ID 和 Profile → APP ID → 找到你的 APP ID → 发布

按流程填写:应用信息、分级与分类、权限用途说明、隐私政策、素材(图标/启动图/截图/视频)

上传安装包,完成检查 → 提交审核

10. 提审前强烈建议:

1.云真机回归

AGC → 开发与服务 → 选择项目 → 质量 → 云调试(云真机)

多机型跑用例:首次启动、权限拒绝/允许、深色模式、分辨率适配、音视频设备权限、异常网络

修复兼容性问题后再提交,显著提高过审率

2.图标配置
在manifest.json中记得配置好下面三张图,提高过审几率

继续阅读 »

适用对象:会用 uni-app / uni-app x 开发应用的前端 / 全栈同学。
目标:从 项目初始化 → 端能力接入 → 调试与适配 → 打包签名 → 商店上架,一次走通。

1. 环境与账号准备

安装工具

HBuilderX(建议最新,含 uni-app/uni-app x 支持)

DevEco Studio(真机/模拟器调试、日志、证书校验)

Node.js(如使用 Vite 生态)

账号与材料

开发者账号:注册并完成实名认证

应用信息:预创建应用,确定 包名/BundleName;准备上架素材(图标、启动图、截图、隐私协议)

签名文件 & Profile:按平台指引生成,后续打包用

小贴士:包名一旦发版不要改;签名(证书/私钥/Profile)必须与应用信息一致。

2. 选型与项目初始化

创建项目

HBuilderX → 文件 → 新建 → 项目(见下图)

目录要点
├── pages/ # 页面(你的业务代码)
├── static/ # 静态资源(音频/图片/字体)
├── manifest.json # 应用配置、权限、图标、启动图
└── pages.json # 路由与导航栏配置

建议:先用 H5 模式验证交互与路由,再上模拟器/真机,缩短调试回路。

3. DevEco Studio 模拟器(先跑通流程)

如果只是演练流程,可先起模拟器:

在 DevEco Studio 新建空项目 → 打开 设备管理器

选择默认模拟器型号

安装后点击启动

启动完成即有一台“真机”可用

注意(以你当前环境为例):DevEco 5.1.1 Beta 下,下载 API 19 模拟器即可运行 uni-app 鸿蒙项目与元服务,其它模拟器暂不支持。

4. 调试证书(HBuilderX 一键配置)

进入 AGC:https://developer.huawei.com/consumer/cn/service/josp/agc/index.html#

创建 APP ID(包名要与项目一致)

回到 uni-app 项目,打开 manifest.json → 鸿蒙 App 配置 → 调试证书配置 → 配置

填入与 AGC 一致的 包名 → 点击 一键配置 → AGC 授权后自动生成证书 → 保存

5. 本地调试(H5→模拟器/真机)

H5 调试(建议先走一遍,保证功能基本正确)

鸿蒙端调试

HBuilderX 顶部 运行 → 运行到手机或模拟器 → 运行到鸿蒙

选择设备;若无设备,刷新或重启模拟器后再刷新

点击运行,自动打包并安装到模拟器进行调试

通过标准:完成核心流程、权限弹窗、前后台切换、冷启动、异常路径回归后再考虑发布。

6. 发布证书(生成 & 申请)

6.1 在 DevEco Studio 生成私钥与 CSR

DevEco Studio → 随便新建个项目 → 构建 → 生成私钥和证书请求(CSR)文件

弹窗中 New → 选一处目录(文中称 目录 A),密码保持一致

记住 Alias(后续会用)→ 下一步

将 CSR 文件保存到 目录 A

点击 Finish,完成 CSR 生成

6.2 在 AGC 申请发布证书 & Profile

AGC → 证书/APP ID/Profile → APP ID → 证书 → 新增证书

选择 发布证书,上传本地 CSR(目录 A),名称随意

保存并下载证书到 目录 A

进入 Profile → 按引导 新增 Profile → 下载到 目录 A

7. 在 HBuilderX 配置发布证书

打开项目 manifest.json → 鸿蒙 App 配置 → 正式证书配置 → 配置

证书/私钥/Profile 均从 目录 A 选择(同后缀基本不会选错)

私钥别名填写之前记录的 Alias → 保存

8. 本地打包(生成鸿蒙安装包)

HBuilderX → 发行 → HarmonyOS 本地打包(名称以你版本为准)

成功后获得鸿蒙安装产物(包含 HAP 的安装包)

自测清单:冷启动/热启动、横竖屏、网络/离线、权限拒绝后流程、前后台恢复、长列表滚动、音频/图片资源是否打进包

常见坑

包名/证书/Profile 不匹配 → 无法安装或覆盖

静态资源路径不规范 → 音频/图片找不到

权限未声明 → 能力调用失败

9. 上架流程(AGC)

AGC → 证书、APP ID 和 Profile → APP ID → 找到你的 APP ID → 发布

按流程填写:应用信息、分级与分类、权限用途说明、隐私政策、素材(图标/启动图/截图/视频)

上传安装包,完成检查 → 提交审核

10. 提审前强烈建议:

1.云真机回归

AGC → 开发与服务 → 选择项目 → 质量 → 云调试(云真机)

多机型跑用例:首次启动、权限拒绝/允许、深色模式、分辨率适配、音视频设备权限、异常网络

修复兼容性问题后再提交,显著提高过审率

2.图标配置
在manifest.json中记得配置好下面三张图,提高过审几率

收起阅读 »