如果你想做一个简历排版App,需要用到Preferences模块化存储

想体验一个完整的简历排版应用吗?打开鸿蒙应用市场,搜索「简历坊」下载体验,看看模块拖拽排版、STAR案例库、导出预览这些功能实际用起来是什么感觉。体验完了再回来,咱们一起拆解它是怎么做出来的。


写在前面

简历排版这个需求,说大不大,说小也不小。你要是只做一个固定模板的填表工具,那很简单;但如果你想做成模块化的、可以拖拽调整顺序的、还能导出预览的,那就得好好设计数据结构了。

这篇文章我们以「简历坊」这个App为例,从React的实现开始,然后搬到鸿蒙ArkTS上。重点聊模块化数据结构的设计和拖拽排序的实现。

这篇文章聊什么

  1. 模块化数据结构设计 – 怎么把简历拆成多个模块,每个模块有独立的类型和数据
  2. 拖拽排序逻辑 – 怎么实现模块之间的拖拽换位
  3. Preferences存储 – 怎么把模块化的简历数据持久化保存

第一步:设计模块化数据结构

简历本质上是由多个模块组成的:基本信息、教育经历、工作经历、项目经验、技能列表等等。每个模块有自己的数据格式,模块之间有顺序关系。

React版本

// 简历模块的类型定义
const moduleTypes = {
  BASIC_INFO: 'basic_info',       // 基本信息
  EDUCATION: 'education',         // 教育经历
  WORK_EXPERIENCE: 'work_exp',     // 工作经历
  PROJECT: 'project',             // 项目经验
  SKILLS: 'skills',               // 技能列表
  SELF_EVAL: 'self_eval',         // 自我评价
};

// 一个简历模块的数据结构
const resumeModule = {
  id: 'string',              // 模块唯一标识
  type: 'string',            // 模块类型(对应上面的moduleTypes)
  title: 'string',           // 模块标题,比如"工作经历"
  visible: true,             // 是否显示
  order: 0,                  // 排序序号
  data: {},                  // 模块的具体数据,格式根据type不同而不同
};

// 基本信息模块的data格式
const basicInfoData = {
  name: '张三',
  phone: '13800138000',
  email: 'zhangsan@example.com',
  address: '北京市朝阳区',
};

// 工作经历模块的data格式(使用STAR法则)
const workExpData = {
  company: '某科技公司',
  position: '前端工程师',
  startDate: '2024-01',
  endDate: '至今',
  cases: [
    {
      situation: '公司官网需要重构',
      task: '负责前端架构设计和开发',
      action: '使用React+TypeScript重构,引入微前端方案',
      result: '页面加载速度提升60%,用户留存率提高15%',
    }
  ]
};

你看,每个模块都有一个type字段来区分类型,data字段存放具体数据。不同类型的模块,data的格式不一样。这种设计的好处是:你可以统一管理所有模块(增删改查排序),同时每个模块的数据又是独立的,互不干扰。

ArkTS版本

// 模块类型枚举
enum ModuleType {
  BASIC_INFO = 'basic_info',
  EDUCATION = 'education',
  WORK_EXPERIENCE = 'work_exp',
  PROJECT = 'project',
  SKILLS = 'skills',
  SELF_EVAL = 'self_eval',
}

// STAR案例的数据结构
interface StarCase {
  situation: string;   // 情境:当时的背景和问题
  task: string;         // 任务:你需要做什么
  action: string;       // 行动:你具体做了什么
  result: string;        // 结果:最终的效果和数据
}

// 工作经历的数据
interface WorkExpData {
  company: string;
  position: string;
  startDate: string;
  endDate: string;
  cases: StarCase[];
}

// 基本信息的数据
interface BasicInfoData {
  name: string;
  phone: string;
  email: string;
  address: string;
}

// 简历模块的通用结构
interface ResumeModule {
  id: string;
  type: ModuleType;
  title: string;
  visible: boolean;
  order: number;
  data: BasicInfoData | WorkExpData | StarCase[] | string;  // 联合类型
}

ArkTS版本用了enum来定义模块类型,比字符串常量更规范,编译器能帮你检查类型。data字段用了联合类型BasicInfoData | WorkExpData | StarCase[] | string,表示它可以是多种类型之一。使用的时候需要通过type字段判断具体是哪种类型,然后做类型断言。

你可能会问,为什么不给每种模块类型定义一个单独的interface?比如BasicInfoModuleWorkExpModule之类的。当然可以,但那样管理起来会比较分散。用统一的ResumeModule加联合类型的data字段,在存储和排序时更方便,因为所有模块都是同一个类型。

第二步:拖拽排序的实现

拖拽排序是简历排版App的核心交互。用户长按一个模块,然后拖到另一个位置松手,两个模块就交换了位置。

React版本

function useDragSort(modules, onSort) {
  const [dragIndex, setDragIndex] = useState(null);
  const [dropIndex, setDropIndex] = useState(null);

  const onDragStart = (index) => {
    setDragIndex(index);
  };

  const onDragOver = (e, index) => {
    e.preventDefault();
    setDropIndex(index);
  };

  const onDrop = () => {
    if (dragIndex !== null && dropIndex !== null && dragIndex !== dropIndex) {
      const newModules = [...modules];
      // 把拖拽的元素从原位置移除
      const [moved] = newModules.splice(dragIndex, 1);
      // 插入到目标位置
      newModules.splice(dropIndex, 0, moved);
      // 更新order字段
      newModules.forEach((m, i) => m.order = i);
      onSort(newModules);
    }
    setDragIndex(null);
    setDropIndex(null);
  };

  return { dragIndex, dropIndex, onDragStart, onDragOver, onDrop };
}

React版本的拖拽用的是HTML5原生的Drag and Drop API。onDragStart记录拖拽开始的位置,onDragOver记录当前悬停的位置,onDrop执行实际的排序操作。排序的核心逻辑就是splice:先从原位置取出元素,再插入到目标位置。

ArkTS版本

鸿蒙没有HTML5的Drag and Drop API,但有onTouch事件。我们可以通过触摸事件来实现拖拽效果。

@Entry
@Component
struct ResumeEditorPage {
  @State modules: ResumeModule[] = [];
  @State dragIndex: number = -1;       // 正在拖拽的模块索引
  @State dropIndex: number = -1;        // 拖拽目标位置索引
  private touchStartY: number = 0;      // 触摸开始的Y坐标
  private isDragging: boolean = false;  // 是否正在拖拽

  // 触摸开始
  onTouchStart(index: number, event: TouchEvent) {
    this.touchStartY = event.touches[0].y;
    this.dragIndex = index;
    this.isDragging = false;  // 还没开始拖拽,只是按下了
  }

  // 触摸移动
  onTouchMove(index: number, event: TouchEvent) {
    const currentY: number = event.touches[0].y;
    const deltaY: number = Math.abs(currentY - this.touchStartY);

    // 移动超过20像素才算拖拽,避免误触
    if (deltaY > 20) {
      this.isDragging = true;
    }

    if (this.isDragging) {
      // 根据当前Y坐标计算目标位置
      // 假设每个模块高度约100像素
      const targetIndex: number = Math.floor(currentY / 100);
      this.dropIndex = Math.max(0, Math.min(targetIndex, this.modules.length - 1));
    }
  }

  // 触摸结束
  onTouchEnd() {
    if (this.isDragging && this.dragIndex >= 0 && this.dropIndex >= 0
      && this.dragIndex !== this.dropIndex) {
      // 执行排序
      const newModules: ResumeModule[] = [...this.modules];
      const moved: ResumeModule = newModules.splice(this.dragIndex, 1)[0];
      newModules.splice(this.dropIndex, 0, moved);
      // 更新order
      for (let i = 0; i < newModules.length; i++) {
        newModules[i].order = i;
      }
      this.modules = newModules;
      // 保存到Preferences
      this.saveModules();
    }
    // 重置状态
    this.dragIndex = -1;
    this.dropIndex = -1;
    this.isDragging = false;
  }
}

鸿蒙的触摸事件和Web的touch事件类似,event.touches[0].y获取第一个触摸点的Y坐标。我们通过计算Y坐标的偏移来判断用户想拖到哪个位置。

20像素的阈值是为了区分"点击"和"拖拽"。如果用户只是轻轻点了一下,不应该触发拖拽。只有手指移动超过20像素,才认为是拖拽操作。

Math.max(0, Math.min(targetIndex, this.modules.length - 1))确保目标索引不会越界。Math.floor(currentY / 100)是粗略估算,实际项目中应该根据组件的实际位置来计算。

第三步:用Preferences存储简历数据

简历数据需要持久化保存,这样用户关闭App再打开,数据还在。

React版本

function saveResume(modules) {
  localStorage.setItem('resume_modules', JSON.stringify(modules));
}

function loadResume() {
  const data = localStorage.getItem('resume_modules');
  return data ? JSON.parse(data) : getDefaultModules();
}

// 默认的简历模块
function getDefaultModules() {
  return [
    { id: '1', type: 'basic_info', title: '基本信息', visible: true, order: 0, data: {} },
    { id: '2', type: 'education', title: '教育经历', visible: true, order: 1, data: [] },
    { id: '3', type: 'work_exp', title: '工作经历', visible: true, order: 2, data: [] },
    { id: '4', type: 'skills', title: '技能列表', visible: true, order: 3, data: '' },
  ];
}

ArkTS版本

import { preferences } from '@kit.ArkData';

// 获取默认模块列表
function getDefaultModules(): ResumeModule[] {
  return [
    {
      id: 'mod_1', type: ModuleType.BASIC_INFO, title: '基本信息',
      visible: true, order: 0,
      data: { name: '', phone: '', email: '', address: '' } as BasicInfoData
    },
    {
      id: 'mod_2', type: ModuleType.EDUCATION, title: '教育经历',
      visible: true, order: 1,
      data: [] as StarCase[]
    },
    {
      id: 'mod_3', type: ModuleType.WORK_EXPERIENCE, title: '工作经历',
      visible: true, order: 2,
      data: [] as StarCase[]
    },
    {
      id: 'mod_4', type: ModuleType.SKILLS, title: '技能列表',
      visible: true, order: 3,
      data: '' as string
    },
  ];
}

// 保存模块数据
async function saveModules(context: Context, modules: ResumeModule[]) {
  const store = await preferences.getPreferences(context, 'resume_store');
  await store.put('resume_modules', JSON.stringify(modules));
  await store.flush();
}

// 加载模块数据
async function loadModules(context: Context): Promise<ResumeModule[]> {
  const store = await preferences.getPreferences(context, 'resume_store');
  const data = await store.get('resume_modules', '');
  if (data === '') {
    return getDefaultModules();
  }
  return JSON.parse(data as string) as ResumeModule[];
}

这里有个细节:默认值的处理。React版本里localStorage.getItem返回null时我们返回默认模块。ArkTS版本里,我们用空字符串''作为默认值(因为Preferences的get方法需要提供一个默认值),然后判断如果data是空字符串就返回默认模块列表。

第四步:STAR案例库

STAR法则是写简历的黄金标准:Situation(情境)、Task(任务)、Action(行动)、Result(结果)。我们做一个案例库,让用户可以积累和管理自己的STAR案例。

ArkTS版本

// 添加一个STAR案例到工作经历模块
function addStarCase(modules: ResumeModule[], moduleId: string, newCase: StarCase) {
  const index: number = modules.findIndex(m => m.id === moduleId);
  if (index >= 0) {
    const moduleData = modules[index].data as WorkExpData;
    if (moduleData.cases) {
      moduleData.cases.push(newCase);
    } else {
      moduleData.cases = [newCase];
    }
  }
}

// STAR案例编辑表单组件
@Entry
@Component
struct StarCaseEditor {
  @State situation: string = '';
  @State task: string = '';
  @State action: string = '';
  @State result: string = '';

  build() {
    Column({ space: 16 }) {
      Text('添加STAR案例')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)

      // Situation
      Column({ space: 4 }) {
        Text('S - 情境(当时的背景和问题)')
          .fontSize(14)
          .fontColor('#666666')
        TextArea({ placeholder: '描述当时的背景...' })
          .onChange(value => this.situation = value)
      }

      // Task
      Column({ space: 4 }) {
        Text('T - 任务(你需要做什么)')
          .fontSize(14)
          .fontColor('#666666')
        TextArea({ placeholder: '描述你的任务...' })
          .onChange(value => this.task = value)
      }

      // Action
      Column({ space: 4 }) {
        Text('A - 行动(你具体做了什么)')
          .fontSize(14)
          .fontColor('#666666')
        TextArea({ placeholder: '描述你的行动...' })
          .onChange(value => this.action = value)
      }

      // Result
      Column({ space: 4 }) {
        Text('R - 结果(最终的效果和数据)')
          .fontSize(14)
          .fontColor('#666666')
        TextArea({ placeholder: '描述最终结果,尽量用数据...' })
          .onChange(value => this.result = value)
      }

      Button('保存案例')
        .width('100%')
        .type(ButtonType.Capsule)
        .onClick(() => {
          const newCase: StarCase = {
            situation: this.situation,
            task: this.task,
            action: this.action,
            result: this.result,
          };
          // 这里调用addStarCase保存
        })
    }
    .padding(16)
  }
}

STAR编辑器就是一个表单,四个字段分别对应STAR的四个字母。每个字段用TextArea多行输入,因为STAR案例通常比较长,单行输入框不够用。

第五步:完整的简历编辑页面

@Entry
@Component
struct ResumeEditorPage {
  @State modules: ResumeModule[] = [];
  @State dragIndex: number = -1;

  async aboutToAppear() {
    this.modules = await loadModules(getContext(this));
  }

  build() {
    Column() {
      // 顶部操作栏
      Row() {
        Text('简历编辑')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .layoutWeight(1)

        Button('预览')
          .fontSize(14)
          .type(ButtonType.Capsule)
          .onClick(() => {
            // 跳转到预览页面
          })
      }
      .padding(16)
      .width('100%')

      // 模块列表
      List({ space: 12 }) {
        ForEach(this.modules, (module: ResumeModule, index: number) => {
          ListItem() {
            Row() {
              // 拖拽手柄
              Text('::')
                .fontSize(20)
                .fontColor('#CCCCCC')
                .margin({ right: 12 })

              // 模块标题
              Text(module.title)
                .fontSize(16)
                .fontWeight(FontWeight.Medium)
                .layoutWeight(1)

              // 显示/隐藏切换
              Text(module.visible ? '显示' : '隐藏')
                .fontSize(12)
                .fontColor(module.visible ? '#4ECDC4' : '#999999')
            }
            .width('100%')
            .padding(16)
            .borderRadius(12)
            .backgroundColor(this.dragIndex === index ? '#FFF3E0' : '#FFFFFF')
            .shadow({ radius: 4, color: '#00000010', offsetY: 2 })
            .onTouch((event: TouchEvent) => {
              if (event.type === TouchType.Down) {
                this.onTouchStart(index, event);
              } else if (event.type === TouchType.Move) {
                this.onTouchMove(index, event);
              } else if (event.type === TouchType.Up) {
                this.onTouchEnd();
              }
            })
          }
        }, (module: ResumeModule) => module.id)
      }
      .padding(16)
      .layoutWeight(1)

      // 添加模块按钮
      Button('添加模块')
        .width('80%')
        .margin({ bottom: 20 })
        .type(ButtonType.Capsule)
        .onClick(() => {
          // 显示模块选择弹窗
        })
    }
    .width('100%')
    .height('100%')
  }
}

这里用List组件替代了之前的ForEach + Column,因为List组件天然支持懒加载,当模块很多时性能更好。ListItem里的onTouch事件处理拖拽逻辑,被拖拽的模块背景色会变成浅橙色,给用户一个视觉反馈。

流程图

没有

用户打开简历编辑器

从Preferences加载模块数据

是否有已保存的数据?

显示已保存的模块列表

显示默认模块列表

用户编辑模块内容

用户拖拽模块?

记录拖拽起始位置

计算拖拽目标位置

更新模块order

保存到Preferences

用户点击编辑

打开STAR案例编辑器

填写STAR四个字段

保存案例到模块data

用户点击预览

按order排序模块

渲染简历预览页面

React vs ArkTS 对比表

功能点 React (Web) ArkTS (鸿蒙)
数据存储 localStorage Preferences
拖拽实现 HTML5 Drag and Drop API onTouch触摸事件
模块类型 字符串常量 enum枚举
联合类型 TypeScript union ArkTS union
列表渲染 array.map() List + ListItem
多行输入 textarea TextArea组件
长按检测 onDragStart onTouch + 阈值判断

总结

这篇文章我们聊了简历排版App的核心实现:

  1. 模块化数据结构 – 用统一的ResumeModule接口管理所有模块,type字段区分类型,data字段存放具体数据
  2. 拖拽排序 – 鸿蒙用onTouch事件模拟拖拽,通过计算触摸坐标来确定目标位置
  3. STAR案例库 – 四个字段对应STAR法则,用TextArea做多行输入

核心API就是Preferences,用来持久化存储模块化的简历数据。如果你对完整的「简历坊」App感兴趣,去鸿蒙应用市场搜索下载体验一下吧。

更多推荐