“上海比伦敦快八小时”适合解释某一天的时钟差,却不适合写进长期运行的会议应用。把城市换算实现成时间戳加减几个小时,代码会很短,错误也会很隐蔽:冬季看起来正确,夏季偏一小时;界面显示了正确钟点,却漏掉已经跨到第二天的日期。时间显示应当依赖地点规则,而不是开发者记住的差值。

同一瞬间在不同城市显示的技术封面

时间戳、时区和语言是三件事

MeetingZones 的目标很小:给定一个确定瞬间,同时显示上海、伦敦和纽约的当地日期与时间。页面提供冬季和夏季两个固定输入,方便观察规则变化。它不创建日历事件,也不从用户输入反推时间,更没有后台提醒。这个范围足以说明为什么时区属于格式化条件,而不是随意修改原始时间的理由。

时间戳表示时间线上的一个点。时区决定这个点对应当地日历上的哪一天、几点;语言区域设置决定这些字段如何排列、使用什么分隔形式。把界面语言设为中文,不代表时间应该自动采用中国时区;把时区设为纽约,也不代表界面文字必须变成英文。三个概念分开后,多城市展示才能保持一致。

时区使用 IANA 标识,例如 Asia/Shanghai、Europe/London 和 America/New_York。它们代表带有历史与变化规则的地点,不只是当前偏移量。夏令时只是规则变化的一种来源,时区数据也会随着相关地区的决定更新。应用应使用系统提供的规则,不要自己维护几条“每年某月加一小时”的近似公式。

本文工程基线设为 API 20 及以上。当前 HarmonyOS 官方文档已将 LocalizationKit 中旧的 intl.DateTimeFormat 标为从 API 20 废弃,并推荐标准全局 Intl.DateTimeFormat。下面直接使用推荐入口,不再导入小写 intl。这里说明的是本例的兼容基线,不是宣称所有标准国际化能力都在这个版本首次出现。

对维护较低版本的现有工程,接口迁移与业务模型修正可以分开安排。即使暂时继续使用对应版本支持的旧入口,保存确定瞬间、显式指定时区的原则也不变。不要为了迁移一个类名,就顺便把原本有明确含义的时间字段改成模糊的显示字符串。

数据层只保存一次确定的瞬间

两个示例分别取二〇二六年一月十五日和七月十五日的世界协调时十四点。它们当然是不同瞬间;切换按钮改变输入日期,同一屏的三个城市则共享当前选择的同一个时间戳。把这个关系讲清楚,才不会把不同日期之间的对比误称为对同一瞬间的重复换算。

新建 API 20 及以上 Stage 工程,在 common/MeetingTime.ets 中放入下面代码。Date.UTC 的月份从零开始,因此一月传零、七月传六。函数先验证数值与日期是否有效,再为每个城市创建格式化器。所有输出准备完成后才返回,避免一个城市失败时得到半新半旧的列表。

// entry/src/main/ets/common/MeetingTime.ets
interface CityZone {
  city: string;
  zone: string;
}

export interface MeetingRow {
  city: string;
  zone: string;
  text: string;
}

const CITIES: CityZone[] = [
  { city: '上海', zone: 'Asia/Shanghai' },
  { city: '伦敦', zone: 'Europe/London' },
  { city: '纽约', zone: 'America/New_York' }
];

export function sampleInstant(summer: boolean): number {
  return Date.UTC(2026, summer ? 6 : 0, 15, 14, 0, 0);
}

export function meetingRows(epochMs: number): MeetingRow[] {
  if (!Number.isFinite(epochMs)) {
    throw new Error('时间戳必须是有限数值');
  }
  const instant: Date = new Date(epochMs);
  if (!Number.isFinite(instant.getTime())) {
    throw new Error('时间戳超出日期范围');
  }
  const rows: MeetingRow[] = [];
  for (const city of CITIES) {
    const options: Intl.DateTimeFormatOptions = {
      timeZone: city.zone,
      calendar: 'gregory',
      numberingSystem: 'latn',
      year: 'numeric',
      month: '2-digit',
      day: '2-digit',
      hour: '2-digit',
      minute: '2-digit',
      hourCycle: 'h23'
    };
    const formatter: Intl.DateTimeFormat = new Intl.DateTimeFormat('zh-CN', options);
    rows.push({ city: city.city, zone: city.zone, text: formatter.format(instant) });
  }
  return rows;
}

代码没有对时间戳加八小时,也没有把日期对象改成某个城市的“本地对象”。三个格式化器读取同一份 instant,只在显示时采用不同规则。这一点对排序与比较尤其重要:会议先后应比较原始瞬间,不能比较格式化后的字符串,否则不同日期顺序和不同语言格式会改变排序含义。

接口名中的毫秒单位也必须落实到数据协议。若服务端返回以秒计数的时间戳,不能原样传给这里的日期构造器;需要在明确协议后转换一次,并在边界处验证。反过来,对已经是毫秒的数据再次乘以一千,也会得到完全错误的日期。单位错误与时区错误都可能表现为时间异常,排查时应先检查原始输入。

显式设置公历、拉丁数字与二十四小时制,是为了让这个教学页面的对照清楚。正式产品可以依据用户偏好决定这些选项,但不应随意改变事件本身。格式化器的配置属于展示策略,原始时间戳属于业务数据,两者应通过清楚的接口连接,避免界面改版污染存储字段。

当前只展示三个城市,点击时创建少量格式化器足够直观,没有必要提前设计复杂缓存。若后续扩展为长列表,可以按语言、时区和选项组合复用对象;缓存键也必须包含全部影响格式的条件。只按城市名缓存,语言改变后仍可能取到旧格式,这类问题通常比节省几个对象更难排查。

页面明确告诉用户正在看哪个日期

下面是完整页面入口,保留模板生成的 Ability 与路由配置。时间格式化是本地能力,本例不需要网络或日历权限。页面只展示固定输入,不读取用户日程;以后接入日历能力时,应按真实功能另行配置,不要把当前界面误解成已经具备事件创建或提醒能力。

// entry/src/main/ets/pages/Index.ets
import { MeetingRow, meetingRows, sampleInstant } from '../common/MeetingTime';

@Entry
@Component
struct Index {
  @State sample: string = '冬季示例';
  @State utc: string = '';
  @State rows: MeetingRow[] = [];
  @State message: string = '三个城市,共享同一个时间戳';

  aboutToAppear(): void {
    this.showSample(false);
  }

  showSample(summer: boolean): void {
    this.sample = summer ? '夏季示例' : '冬季示例';
    try {
      const epochMs = sampleInstant(summer);
      const nextRows = meetingRows(epochMs);
      const nextUtc = new Date(epochMs).toISOString();
      this.rows = nextRows;
      this.utc = nextUtc;
      this.message = '三个城市,共享同一个时间戳';
    } catch (_) {
      this.rows = [];
      this.utc = '';
      this.message = '日期或时区格式化失败,请检查配置';
    }
  }

  build() {
    Scroll() {
      Column({ space: 10 }) {
        Text('跨城会议时间').fontSize(28).fontWeight(FontWeight.Bold)
        Text('同一瞬间,不同当地时间').fontSize(16).fontColor('#64748B')
        Column({ space: 5 }) {
          Text(this.sample).fontSize(14).fontColor('#735984')
          Text(this.utc).fontSize(15).fontFamily('monospace')
        }.padding(12).width('100%').backgroundColor('#EDE6F5').borderRadius(14)
        ForEach(this.rows, (row: MeetingRow) => {
          Column({ space: 5 }) {
            Row() {
              Text(row.city).fontSize(18).fontWeight(FontWeight.Medium)
              Blank()
              Text(row.zone).fontSize(11).fontColor('#64748B')
            }.width('100%')
            Text(row.text).fontSize(24).fontWeight(FontWeight.Medium).width('100%')
          }.padding(12).width('100%').backgroundColor('#FFFFFF').borderRadius(14)
        }, (row: MeetingRow) => `${row.zone}:${row.text}`)
        Row({ space: 10 }) {
          Button('冬季示例').height(44).layoutWeight(1).backgroundColor('#785994')
            .onClick(() => { this.showSample(false); })
          Button('夏季示例').height(44).layoutWeight(1).backgroundColor('#785994')
            .onClick(() => { this.showSample(true); })
        }.width('100%')
        Text(this.message).fontSize(14).fontColor('#735984')
      }.padding(20).width('100%')
    }.height('100%').width('100%').backgroundColor('#F7F4FA')
  }
}

界面同时显示原始世界协调时表示、城市名称、时区标识和完整当地日期。只写“晚上十点”虽然短,却省掉了跨日信息;只写城市简称,也不便于核对实际采用的规则。练习页保留这些细节,是为了让输入与输出能够对照,正式产品可以再依据用户任务调整信息密度。

三个展示行的键同时包含时区和当前显示文字。切换样本后,文字变化会重建相应行,避免替换了普通对象数组却继续复用绑定旧对象的显示组件;这里只承载少量无内部编辑状态的文本卡片。

异常分支清空旧列表,避免配置错误后仍然展示上一次成功结果。城市集合固定并不意味着永远不需要处理异常:后续可能加入配置下发、用户选择或日期输入,错误更容易进入格式化层。保持失败路径清晰,可以让这些扩展沿用同一契约,而不是默默退回设备当前时区。

夏季示例在三个城市的完整预期页面

用季节变化检验固定差值的假设

先选择冬季示例。对应输入下,上海为当日二十二点,伦敦为十四点,纽约为九点。再选择夏季示例,上海仍为二十二点,伦敦变为十五点,纽约变为十点。这些是固定日期输入与时区规则的换算结果,不能被描述为设备计时误差或格式化性能测量。

比较的重点不是数字长得不同,而是同一个城市与世界协调时之间的关系随日期变化。若代码采用固定小时差,冬季样本通过后,夏季样本就可能暴露错误。只验证当前日期容易遗漏这一点,因此时区测试应包含不同季节与跨日情况,而不是每次都用运行时的“现在”。

接着将示例时刻暂时改为世界协调时二十三点三十分,检查上海日期是否进入下一天。观察应同时覆盖年月日与小时分钟,而不是只盯着时钟部分。月末、年末和闰日也值得作为明确输入保存,便于重复检查。测试的是日历边界,输入必须可定位,不能依赖人工等待真实午夜。

还可以修改设备自身时区后重看固定输入。由于三个城市都显式传入 timeZone,它们的当地日期与时间不应随设备时区变化。若发生变化,首先检查是否有某处漏传时区,或在构造输入时使用了本地年月日构造方式。显示层再正确,也无法修复一个一开始就解释错的瞬间。

失败路径可以用有限、明确的输入检查。将时间戳替换为无效数或超出日期范围的数值,预期函数拒绝处理;将配置中的时区临时改成无效标识,预期页面显示失败并清空列表。验证后恢复固定配置,不应为了让错误样本“看起来还能用”而悄悄改用默认时区。

格式化结果不适合重新解析回业务时间。不同版本的国际化数据可能改变分隔符、空格或文字形式,这些变化不等于事件发生了改变。验收时应核对日期字段、钟点和时区语义;界面排版也应留出文字变化空间,不能把某一种分隔符和固定字符宽度当作永久协议。

输入瞬间、时区标识和季节切换的操作标注

显示正确,只解决了时间问题的一半

从确定瞬间转换成当地显示,方向清楚;从用户填写的当地时间反推瞬间,问题会复杂得多。夏令时切换可能让某些当地钟点不存在,或让同一个钟点出现两次。这个页面没有实现反向解析,因此不能直接拿三个输入框替换文字,就宣称已经完成跨时区会议创建器。

还有一类数据只有日期,没有确定瞬间,例如某个纪念日或按当地日历定义的全天事项。把它强行存成世界协调时零点,再按城市显示,可能改变用户原本选择的日期。应先确认业务字段表达日期、当地日期时间还是确定瞬间,然后选择对应模型,不能把所有带年月日的信息都塞进同一种时间戳。

重复会议也需要自己的语义。例如“每周当地上午九点”与“每隔固定小时数”表达不同规则。前者需要保存会议所依据的时区和当地时间约定,后者强调时间线上固定间隔。若只存第一次发生的时间戳,再不断加上固定时长,可能偏离用户理解的当地日程。

时区数据会更新,所以长期预约还需要明确产品如何处理规则变化。对于已经发生的事件,通常关注准确记录;对于未来事件,则需要明确遵循最新当地规则还是保留既定瞬间。这是业务决策,不能藏在一个格式化函数的默认值里,也不应该由界面开发者独自猜测。

维护时间相关代码时,我更看重字段含义是否完整,而不是换算函数有多短。确定瞬间、地点规则和展示偏好各自有名字,代码评审才能看出它们何时发生变化。这个三城市页面没有解决所有日历问题,但它把最常见的固定差值捷径移出了基础模型,让后续功能有了可继续扩展的起点。

官方参考

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐