阅读视图

发现新文章,点击刷新页面。

基于腾讯地图实现电子围栏绘制与校验

需求背景:在安全巡检系统中,为巡检人员配置“电子围栏”,当人员在围栏内(或异常停留超时)触发告警。业务需要一个可配置、可编辑、可校验的围栏编辑器,支持多边形/矩形绘制、相交检测、搜索定位、缩略图生成上传和启停状态设置。

image.png

1. 组件背景与业务场景

  • 业务目标:为巡检系统配置“电子围栏”,限定巡检活动区域,配合异常停留时限与启停状态形成完整的策略。
  • 使用人群:业务管理员/调度人员;交互上要求“易绘制、可编辑、易清空、可搜索定位”。
  • 数据形态:围栏区域以坐标序列存储(多边形/矩形路径),序列化为 JSON 持久化到后端。
  • 辅助要素:提交前需校验围栏是否相交,生成围栏缩略图用于列表/详情展示。

界面入口为对话框模式(Dialog),包含基础表单与地图绘制区:

  • 围栏区域名称、异常停留时限(分钟)、启停状态;
  • 地图区域提供绘制/编辑/删除/一键删除、形状切换(多边形/矩形)、地点搜索。

image.png


2. 核心功能点与交互流程拆解

  • 模式切换:绘制模式(DRAW)/编辑模式(INTERACT)/删除单个/一键删除全部。
  • 工具切换:多边形与矩形两类覆盖物的快速切换。
  • 搜索定位:联想输入+节流调用,点击候选项在地图上定位并弹出信息窗。
  • 坐标收集:监听绘制与编辑完成事件,实时收集 polygon/rectangle 的路径点,序列化到表单字段 fenceArea。
  • 相交检测:提交前对所有区域两两进行相交判断,避免配置出重叠区域。
  • 缩略图生成:使用 Canvas 将围栏几何映射到可视缩略图,上传并记录返回的 URL。
  • 资源清理:组件卸载时销毁编辑器与地图实例,释放内存。

基本链路如下:

  1. 打开弹窗 → 根据类型(新建/编辑/查看)设置标题与编辑模式
  2. 初始化地图与编辑器 → 注入已有几何 → 绑定 draw/adjust 完成事件
  3. 绘制/编辑过程中更新 fenceArea → 搜索定位辅助操作
  4. 提交:停止编辑器 → 收集/校验坐标 → 生成并上传缩略图 → 调用创建/更新接口

3. 技术选型与实现要点

3.1 地图与几何编辑:TMap GeometryEditor

  • 地图基座:TMap.Map
  • 覆盖物:TMap.MultiPolygon(多边形) 与 TMap.MultiRectangle(矩形)
  • 编辑器:TMap.tools.GeometryEditor,支持 actionMode(激活模式)、activeOverlay(激活覆盖物)、snappable/selectable 等配置
  • 事件监听:draw_complete(绘制完成)、adjust_complete(编辑完成)

示例代码initMap:

const initMap = () => {
  map = new TMap.Map("map-container", {
    zoom: 16,
    center: new TMap.LatLng(latitude.value, longitude.value),
    showControl: false,
  });

  // 已有几何解析与注入(编辑/查看)
  const polygonGeometries: any[] = [];
  if ((formType.value === "update" || formType.value === "view") && formData.value.fenceArea) {
    const geometries = JSON.parse(formData.value.fenceArea);
    geometries.forEach((geo) => {
      polygonGeometries.push({
        id: `polygon_${polygonGeometries.length}`,
        paths: geo.paths.map((p) => new TMap.LatLng(p.lat, p.lng)),
      });
    });
  }

  // 多边形与矩形覆盖物
  polygon = new TMap.MultiPolygon({ map, geometries: polygonGeometries });
  rectangle = new TMap.MultiRectangle({ map, geometries: [] });

  // 编辑器绑定
  editor = new TMap.tools.GeometryEditor({
    map,
    overlayList: [
      { overlay: polygon, id: "polygon", styles: { highlight: new TMap.PolygonStyle({ color: "rgba(255,255,0,.6)" }) }, selectedStyleId: "highlight" },
      { overlay: rectangle, id: "rectangle", styles: { highlight: new TMap.PolygonStyle({ color: "rgba(255,255,0,.6)" }) }, selectedStyleId: "highlight" },
    ],
    actionMode: "", // 由外部模式切换驱动
    activeOverlayId: activeType.value,
    snappable: !isViewMode.value,
    selectable: !isViewMode.value,
  });

  // 绘制/编辑完成后更新数据
  editor.on("draw_complete", updateFenceArea);
  editor.on("adjust_complete", updateFenceArea);
};

模式切换实现(绘制/编辑/删除/一键删除):

const handleModeChange = (id: "draw"|"edit"|"delete"|"deletes") => {
  if (activeMode.value === id && id !== "delete" && id !== "deletes") return;

  switch (id) {
    case "draw":
      editor.stop();
      editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW);
      activeMode.value = id;
      break;
    case "edit":
      editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT);
      activeMode.value = id;
      break;
    case "delete":
      editor.delete();
      updateFenceArea();
      break;
    case "deletes":
      // 临时切换到编辑模式,批量选择并删除所有几何
      const wasInDrawMode = activeMode.value === "draw";
      if (wasInDrawMode) {
        activeMode.value = "edit";
        editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT);
      }
      editor.select([]);
      const polygonIds = polygon?.geometries?.map((g) => g.id) || [];
      const rectIds = rectangle?.geometries?.map((g) => g.id) || [];
      if (polygonIds.length) { editor.setActiveOverlay("polygon"); editor.select(polygonIds); editor.delete(); }
      if (rectIds.length) { editor.setActiveOverlay("rectangle"); editor.select(rectIds); editor.delete(); }
      updateFenceArea();
      if (wasInDrawMode) {
        activeMode.value = "draw";
        editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW);
      }
      break;
  }
};

工具切换(多边形/矩形)仅需切换 activeOverlayId:

const handleToolChange = (id: "polygon"|"rectangle") => {
  if (activeType.value === id) return;
  activeType.value = id;
  editor.setActiveOverlay(id);
};

3.2 坐标收集与相交检测

  • 目标:统一收集 polygon/rectangle 的路径坐标,序列化为字符串到 fenceArea

  • 相交检测:两两比较所有多边形路径,借助 TMap.geometry.computePolygonIntersection 判断是否相交,若相交阻断提交

const updateFenceArea = () => {
  const geometries: any[] = [];
  const allPolygons: any[] = [];

  if (polygon?.geometries?.length) {
    polygon.geometries.forEach((geo) => {
      geometries.push({ type: "polygon", paths: geo.paths });
      allPolygons.push(geo.paths);
    });
  }
  if (rectangle?.geometries?.length) {
    rectangle.geometries.forEach((geo) => {
      geometries.push({ type: "rectangle", paths: geo.paths });
      allPolygons.push(geo.paths);
    });
  }

  // 多边形两两相交检测
  if (allPolygons.length > 1) {
    let hasIntersection = false;
    for (let i = 0; i < allPolygons.length - 1; i++) {
      for (let j = i + 1; j < allPolygons.length; j++) {
        const inter = TMap.geometry.computePolygonIntersection(
          allPolygons[i].map((p) => new TMap.LatLng(p.lat, p.lng)),
          allPolygons[j].map((p) => new TMap.LatLng(p.lat, p.lng))
        );
        if (inter && inter.length > 0) { hasIntersection = true; break; }
      }
      if (hasIntersection) break;
    }
    if (hasIntersection) {
      message.error("围栏区域不能相交或重叠,请调整区域位置!");
      return false;
    }
  }

  formData.value.fenceArea = geometries.length ? JSON.stringify(geometries) : undefined;
  return true;
};

3.3 缩略图绘制与上传

  • 动机:列表/详情等界面快速预览围栏形状,减少进入地图的成本

  • 方法:将所有几何的经纬度投影到 canvas 坐标系;取坐标极值计算缩放与居中,绘制填充+描边

const drawFenceThumbnail = async () => {
  if (!formData.value.fenceArea) return;

  const canvas = document.createElement("canvas");
  canvas.width = 384; canvas.height = 216;
  const ctx = canvas.getContext("2d"); if (!ctx) return;

  // 背景图可替换为项目默认底图
  const bg = await new Promise<HTMLImageElement>((res, rej) => {
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => res(img);
    img.onerror = rej;
    img.src = "https://via.placeholder.com/384x216.png?text=BG";
  });
  ctx.drawImage(bg, 0, 0, canvas.width, canvas.height);

  const geometries = JSON.parse(formData.value.fenceArea);
  let minLat=Infinity,maxLat=-Infinity,minLng=Infinity,maxLng=-Infinity;
  geometries.forEach((g) => g.paths.forEach((p:any) => {
    const lat = p.lat || p.latitude; const lng = p.lng || p.longitude;
    minLat = Math.min(minLat, lat); maxLat = Math.max(maxLat, lat);
    minLng = Math.min(minLng, lng); maxLng = Math.max(maxLng, lng);
  }));

  const padding = 10;
  const contentW = canvas.width - padding * 2;
  const contentH = canvas.height - padding * 2;
  const latRange = maxLat - minLat; const lngRange = maxLng - minLng;
  let scale = Math.min(contentW / lngRange, contentH / latRange) * 0.9; // 安全边距
  const centerLng = (minLng + maxLng) / 2; const centerLat = (minLat + maxLat) / 2;
  const cx = canvas.width / 2; const cy = canvas.height / 2;

  ctx.strokeStyle = "rgba(252,193,31,.70)";
  ctx.lineWidth = 2; ctx.fillStyle = "rgba(219,132,38,.40)";

  geometries.forEach((g:any) => {
    ctx.beginPath();
    g.paths.forEach((p:any, idx:number) => {
      const x = cx + ( (p.lng||p.longitude) - centerLng ) * scale;
      const y = cy - ( (p.lat||p.latitude) - centerLat ) * scale;
      idx === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    });
    ctx.closePath(); ctx.fill(); ctx.stroke();
  });

  const blob = await new Promise<Blob|null>((res) => canvas.toBlob(res, "image/png"));
  if (!blob) return;
  const file = new File([blob], `fence-thumbnail-${Date.now()}.png`, { type: "image/png" });
  const uploadResult = await httpRequest({ file: file as any, action: uploadUrl, method: "POST", filename: "file", data: {} });
  if (uploadResult?.data) formData.value.thumbnail = uploadResult.data;
};

(背景图为示例图片) image.png

3.4 搜索联想与定位

  • 关键点:节流调用、错误码处理(如频率限制)、定位后居中并显示信息窗
const getSuggestions = throttle(() => {
  if (!address.value) { suggestionList.value = []; return; }
  suggest.getSuggestions({ keyword: address.value, location: map.getCenter() })
    .then((result) => { suggestionList.value = result.data; })
    .catch((error) => {
      if (error.status == 120) message.error("搜索过于频繁,请稍后再试");
      else message.error("搜索失败," + error.message + ",请联系系统管理员");
    });
}, 500);

function setSuggestion(item) {
  suggestionList.value = [];
  infoWindowList.forEach((w) => w.close()); infoWindowList.length = 0;
  address.value = item.title;
  const w = new TMap.InfoWindow({ map, position: item.location, content: `<h3>${item.title}</h3><p>地址:${item.address}</p>` });
  infoWindowList.push(w);
  map.setCenter(item.location);
}

3.5 打开弹窗、提交与资源清理

  • 打开弹窗时设置标题与编辑模式:
const open = async (type: "create"|"update"|"view", id?: number) => {
  dialogVisible.value = true; formType.value = type; resetForm();
  if (id) { formLoading.value = true; try { formData.value = await PatrolEfenceApi.getPatrolEfence(id); } finally { formLoading.value = false; } }
  nextTick(() => {
    initMap();
    if (type === "update") { dialogTitle.value = "编辑围栏区域"; activeMode.value = "edit"; editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT); }
    else if (type === "create") { dialogTitle.value = "新建围栏区域"; activeMode.value = "draw"; editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW); }
    else { dialogTitle.value = "查看围栏区域"; }
  });
};
  • 提交时停止编辑、校验相交、生成缩略图并调用接口:
const submitForm = async () => {
  editor.stop();
  const isValid = updateFenceArea();
  if (!isValid) return;

  await formRef.value.validate();
  formLoading.value = true;
  await drawFenceThumbnail();

  try {
    const data = formData.value as unknown as PatrolEfenceVO;
    if (formType.value === "create") { await PatrolEfenceApi.createPatrolEfence(data); message.success(t("common.createSuccess")); }
    else { await PatrolEfenceApi.updatePatrolEfence(data); message.success(t("common.updateSuccess")); }
    dialogVisible.value = false; emit("success");
  } finally { formLoading.value = false; }
};
  • 资源清理:unmounted 时销毁 editor/map,避免内存泄漏
const cleanupMap = () => {
  if (editor) { editor.destroy(); editor = null; }
  if (map) { map.destroy(); map = null; }
};
onUnmounted(cleanupMap);

4. 踩坑记录与性能优化经验

  • 编辑器状态一致性

    • 删除“全部”前需临时切到编辑模式以支持批量选择,否则在绘制模式下 delete 不生效。
    • 删除后务必调用 updateFenceArea 刷新序列化数据,避免表单残留旧坐标。
  • 绘制结束与提交时机

    • 提交前调用 editor.stop(),确保几何最新状态已落在 overlay 上,避免“拖动中提交”的状态差异。
  • 缩略图映射边界

    • 经纬度与屏幕坐标是不同空间,先算极值与中心,再缩放至画布;额外乘以 0.9 “安全边距”系数,避免贴边截断。
    • y 轴方向需反转(屏幕坐标向下为正,纬度向上为正)。
  • 搜索联想与调用频率

    • 使用 lodash-es throttle(500ms)降低接口压力。
    • 明确错误码(如 120 过频),给出清晰提示;无结果时清空建议列表。
  • 只读模式开关

    • isViewMode 下将编辑器 snappable/selectable 关闭,减少误触,并减少内部命中测试消耗。
  • 资源释放

    • 组件卸载时销毁 editor/map,防止多次进入弹窗导致堆叠与内存泄漏。

5. 可复用的最佳实践总结

  • 绘制/编辑器模式解耦:用 activeMode/activeType 显式切换 actionMode 与 activeOverlay,状态一目了然。
  • 数据唯一真源:任何绘制/编辑完成后立刻同步到 formData.fenceArea,避免 UI 与数据不同步。
  • 提交防御:提交前停止编辑器 + 相交校验 + 表单校验,条条把关。
  • 缩略图抽象:将“坐标→画布”的映射封装为通用函数,缩略图生成可用于列表/详情/导出。
  • 异步节流与错误处理:联想搜索加节流、提示错误码;降低接口风险提升体验。
  • 组件内清理:onUnmounted 清理地图与编辑器资源,确保弹窗多次打开稳定。
  • 只读模式优化:查看模式下关闭可交互能力,既安全又省资源。

如何用一份 JSON 配置搞定“法律计算器”的动态表单

引言:小明的工伤赔偿奇遇记

想象一下,当事人小明打开我们的“法律计算器”小程序,想算算工伤赔偿。

  • 场景 A:他手抖选了“劳动关系”,页面立刻弹出“月工资是多少?”;
  • 场景 B:他改主意选了“交通事故”,页面瞬间变身,开始问“伤残等级”;
  • 场景 C:他填了个“30000”的月薪,系统立马提示:“哥们,这超过社平工资 3 倍了,你确定没填错?”(后端异步校验)。

作为开发者,你是不是已经开始头疼了?如果针对劳动争议、交通事故、借贷纠纷等等法律业务都分别写一个 .vue 页面,光是维护 v-if/v-else 就得掉一半头发。万一明天产品经理说:“在这个表单中间加个‘案发地点’字段”,你是不是得发版重新提审?

拒绝写死代码! 今天我们来聊聊如何用 数据驱动UI 架构,打造一个“千人千面”的法律计算器。


一、 核心原理:把前端做成“乐高底板”

在传统的开发模式中,前端是“建筑师”,负责设计页面结构(Template);后端只是“搬砖工”,负责提供数据(Data)。

但在 数据驱动UI 架构下,角色反转了。前端退化成了一块纯粹的 “乐高底板”,而后端发来的 JSON 配置,就是那张 “搭建图纸”。前端不关心业务逻辑,只负责一件事:给什么积木,就搭什么房子。

1. 渲染引擎:v-for 的魔法

让我们看看核心渲染引擎 customForm/index.vue 是如何工作的。它的核心逻辑极其简单,就像是在遍历一份清单:

<!-- components/customForm/index.vue (精简版) -->
<template>
  <view class="custom-form">
    <!-- 第一层循环:遍历表单分组 (Section) -->
    <view v-for="(section, sIndex) in formConfig" :key="sIndex">
      <view class="section-title">{{ section.title }}</view>
      
      <!-- 第二层循环:遍历具体的题目 (Items) -->
      <view v-for="(item, iIndex) in section.items" :key="iIndex">
        
        <!-- 积木 A:普通输入框 (component === 3) -->
        <u-form-item v-if="item.component === 3" :label="item.title">
          <u-input v-model="formData[item.qId]" />
        </u-form-item>

        <!-- 积木 B:选择器 (component === 4) -->
        <u-form-item v-if="item.component === 4" :label="item.title">
          <view @click="openPicker(item)">{{ getLabel(item) }}</view>
        </u-form-item>

        <!-- 积木 C:复杂的利率选择器 (component === 9) -->
        <rate-selector 
          v-if="item.component === 9" 
          :init-value="formData[item.qId]"
        />
        
      </view>
    </view>
  </view>
</template>

前端不再写死 <input><select>,而是根据 JSON 中的 component 字段(3 代表输入框,4 代表选择器,9 代表复杂组件)动态决定渲染什么。

2. 每次修改表单都动态获取JSON数据

最精彩的部分来了。既然前端不写 v-if="salary > 30000",那条件分支怎么实现?

答案是:不要在前端做逻辑判断,把用户的每一次交互都告诉后端。

这是一个 (问后端) 的过程。在具体业务代码中,我们监听了表单的每一次变更:

// pages/enterpriseLaw/legalCalculator/form.vue

// 1. 用户修改了答案
handleFormChange(newAnswer) {
  // 更新本地答案池
  this.updateAnswers(newAnswer);
  
  // 2. 核心:带着当前的答案,去问后端“下一步该展示什么?”
  this.getDynamic(); 
},

async getDynamic() {
  // 3. 调用接口,把所有已填答案扔给后端
  const payload = { 
    appId: this.appId, 
    answers: this.answers 
  };
  
  // 4. 后端的大脑开始飞速运转,计算出新的题目列表
  const res = await this.$api.getDynamic(payload);
  
  // 5. 前端拿到新的 JSON,Vue 自动 diff 更新视图
  this.questions = res.data.questions;
}

点睛之笔:这就是“一份 JSON 配置”的真相。逻辑在后端,前端只是负责画图的“画笔”。 这样一来,无论是增加题目、修改逻辑分支,还是调整校验规则,都只需要后端改配置,前端代码一行都不用动!


二、 难点攻克:细节决定成败

痛点一:嵌套条件分支的“配置化”

如果题目之间有复杂的嵌套关系(例如 是否有借款 -> 有几笔 -> 第一笔利息 -> 怎么算的),JSON 结构该怎么设计?

我们采用 Section (分组) -> Group (实例) -> Items (题目) 的三层结构。Vue 的响应式系统在这里帮了大忙。当后端返回的 questions 数组发生变化(比如因为你选了“有借款”,数组里多了一个“借款详情”的 Section),Vue 会自动检测到数据的变化,并高效地修补 DOM。

// 后端返回的 JSON 结构示意
[
  {
    "groupTitle": "基本信息",
    "items": [ ... ]
  },
  {
    "groupTitle": "借款详情", // 只有当用户选了“有借款”才会返回这个 Section
    "isGroup": true,        // 标记为可重复的分组(如多笔借款)
    "items": [ ... ]
  }
]

痛点二:无缝嵌入异步校验

有些校验前端做不了,比如“赔偿金是否符合当地最新的法律标准”。这时候,我们需要把校验权也交给后端。

在代码中,我们设计了一个巧妙的 backendErrors 机制:

  1. 用户填完:触发 validateByBackend
  2. 后端校验:发现 Q101 题目的金额填错了,返回错误 Map:{ "q_101": "金额过大,请确认" }
  3. 前端标红
// customForm/index.vue

// 监听后端传来的错误对象
props: ['backendErrors'],

// 在模板中精准展示错误
<view v-if="backendErrors[item.qId]" class="backend-error">
  {{ backendErrors[item.qId] }}
</view>

这样,异步的业务校验就像本地校验一样自然流畅,用户根本感觉不到请求的延迟。

痛点三:原子组件的扩展(RateSelector)

这时候有人会问:“如果我要一个超级复杂的组件,比如‘LPR 利率计算器’,JSON 配置能描述清楚吗?”

当然可以!这就是 数据驱动UI 的灵活性。我们不需要用 JSON 描述组件内部的每一个 div,而是把这个复杂组件封装成一个原子积木

看看 components/customForm/RateSelector.vue,它内部包含了:

  • 日/月/年利率的切换
  • 百分比/千分比的换算
  • LPR 动态查询

但在表单引擎眼里,它只是一个普通的积木:

// 如果 component 代码是 9,我就渲染 RateSelector
<rate-selector v-if="rawItem.component === 9" ... />

这样,我们既保持了引擎的通用性(处理普通输入框),又保留了处理复杂业务的能力(通过自定义组件扩展)。


三、 总结:从“搬砖”到“搭积木”

数据流转图

最后,让我们用一张图来总结整个流程:

graph TD
    User[用户输入] -->|触发| Event[handleFormChange]
    Event -->|携带 Current Answers| API[调用 getDynamic 接口]
    API -->|发送至| Server[后端逻辑大脑]
    Server -->|计算条件分支/校验| Config[生成新的 JSON 配置]
    Config -->|返回| Frontend[前端 Vue 引擎]
    Frontend -->|Vue Reactivity| DOM[界面无感刷新]
    DOM -->|展示| User

架构优势

  1. 配置热更新: 运营人员想在表单里加一个“备注”字段?改一下数据库里的 JSON 配置就行了。用户端不需要发版,不需要更新,打开小程序就能看到新字段。这在法律法规频繁变动的行业简直是救命稻草。

  2. 逻辑复用: 我们只写了一套 customForm 引擎,却同时支持了“劳动争议”、“交通事故”、“民间借贷”等等法律业务的计算器。每个计算器只是后端数据库里的一条配置记录而已。

“偷懒”是程序员的第一生产力。 把复杂的逻辑甩给后端,把繁琐的渲染交给引擎,我们前端开发者,终于可以安心地喝一杯咖啡了。☕️


如果你对这套代码感兴趣,欢迎在评论区留言

SwiftUI路由管理架构揭秘:从混乱到优雅的蜕变

引言

想象一下:当你打开一个 App,点击不同标签页,切换页面时,所有导航状态都能完美保持;当你从详情页返回时,TabBar 能智能地重新出现;当你需要传递数据时,类型安全的导航能让你告别字符串硬编码的烦恼。这一切,都离不开一个优秀的路由管理架构。

在现代 iOS 应用开发中,路由管理常常被视为"基础设施"而被忽视,但其重要性却不亚于任何核心功能。一个设计良好的路由系统,不仅能让代码结构更清晰,还能显著提升用户体验。今天,我将带大家深入剖析我项目中的路由管理架构,分享从设计到实现的全过程,希望能为你的项目带来启发。

路由架构概览

我项目的路由管理基于 SwiftUI 的 NavigationStackNavigationPath,采用了集中式的路由管理方案。核心组件包括:

  • Router 类:全局导航路由器,管理所有 Tab 的导航路径
  • MainTab 枚举:定义应用的标签页结构
  • MainContainerView:主容器视图,负责整合标签页和导航逻辑
  • App 启动注入:在应用启动时将 Router 注入到环境中

路由的启动注入

EviApp.swift 中,我们通过 @StateObject 创建 Router 实例,并通过 environmentObject 将其注入到应用环境中:

import SwiftUI

@main
struct EviApp: App {
    // 把 AppDelegate 接进来,系统会照常调用 didFinishLaunchingWithOptions 等
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    // 全局弹框管理器
    @StateObject private var overlay = GlobalOverlayManager.shared
    // 全局导航路由器
    @StateObject private var router = Router()
    
    var body: some Scene {
        WindowGroup {
            MainContainerView()
                .environmentObject(overlay)
                .environmentObject(router)
        }
    }
}

这样,在应用的任何视图中,都可以通过 @EnvironmentObject 来访问 Router 实例,实现全局路由管理。

核心组件分析

1. Router 类:路由管理的核心

import SwiftUI

/// 全局导航路由器,管理所有Tab的导航路径
class Router: ObservableObject {
    
    // 当前选中的Tab
    @Published var selectedTab: MainTab = .home
    
    // 为每个tab单独存储NavigationPath
    @Published var homePath = NavigationPath()
    @Published var hotPath = NavigationPath()
    @Published var creationPath = NavigationPath()
    @Published var stylePath = NavigationPath()
    @Published var profilePath = NavigationPath()
    
    // MARK: - 获取导航路径
    
    /// 获取指定tab的导航路径
    func getNavigationPath(for tab: MainTab) -> NavigationPath {
        switch tab {
        case .home: return homePath
        case .hot: return hotPath
        case .creation: return creationPath
        case .style: return stylePath
        case .profile: return profilePath
        }
    }
    
    /// 获取指定tab的导航路径绑定
    func getNavigationPathBinding(for tab: MainTab) -> Binding<NavigationPath> {
        switch tab {
        case .home: return binding(for: \.homePath)
        case .hot: return binding(for: \.hotPath)
        case .creation: return binding(for: \.creationPath)
        case .style: return binding(for: \.stylePath)
        case .profile: return binding(for: \.profilePath)
        }
    }
    
    // MARK: - 清空导航路径
    
    /// 清空指定tab的导航路径
    func clearPath(for tab: MainTab) {
        switch tab {
        case .home: clear(\.homePath)
        case .hot: clear(\.hotPath)
        case .creation: clear(\.creationPath)
        case .style: clear(\.stylePath)
        case .profile: clear(\.profilePath)
        }
    }
    
    /// 清空所有导航路径
    func clearAllPaths() {
        clear(\.homePath)
        clear(\.hotPath)
        clear(\.creationPath)
        clear(\.stylePath)
        clear(\.profilePath)
    }
    
    // MARK: - 当前Tab操作
    
    /// 获取当前选中Tab的导航路径
    func getCurrentNavigationPath() -> NavigationPath {
        return getNavigationPath(for: selectedTab)
    }
    
    /// 获取当前选中Tab的导航路径绑定
    func getCurrentNavigationPathBinding() -> Binding<NavigationPath> {
        return getNavigationPathBinding(for: selectedTab)
    }
    
    /// 清空当前选中Tab的导航路径
    func clearCurrentPath() {
        clearPath(for: selectedTab)
    }
    
    // MARK: - 私有辅助方法
    
    /// 创建导航路径的绑定
    private func binding(for keyPath: ReferenceWritableKeyPath<Router, NavigationPath>) -> Binding<NavigationPath> {
        Binding {
            self[keyPath: keyPath]
        } set: {
            self[keyPath: keyPath] = $0
        }
    }
    
    /// 清空指定的导航路径
    private func clear(_ keyPath: ReferenceWritableKeyPath<Router, NavigationPath>) {
        self[keyPath: keyPath].removeLast(self[keyPath: keyPath].count)
    }
}

设计亮点

  • 集中管理:所有路由逻辑集中在一个类中,便于统一管理
  • Tab 隔离:为每个标签页维护独立的导航路径,确保切换标签时不会影响其他标签的导航状态
  • 响应式设计:使用 @Published 修饰符,实现路由状态的自动更新
  • 便捷方法:提供了丰富的方法来操作导航路径,如获取路径、清空路径等

2. MainTab 枚举:标签页定义

import SwiftUI

/// 主标签栏枚举
enum MainTab {
    case home
    case hot
    case creation
    case style
    case profile
}

extension MainTab {
    
    /// 根据选中状态返回对应的图标名称
    func iconName(isSelected: Bool) -> String {
        switch self {
        case .home:
            return isSelected ? "tabbar_home_sel" : "tabbar_home_nor"
        case .hot:
            return isSelected ? "tabbar_hot_sel" : "tabbar_hot_nor"
        case .creation:
            return "tabbar_add"
        case .style:
            return isSelected ? "tabbar_style_sel" : "tabbar_style_nor"
        case .profile:
            return isSelected ? "tabbar_me_sel" : "tabbar_me_nor"
        }
    }
}

设计亮点

  • 类型安全:使用枚举定义标签页,避免了字符串硬编码
  • 扩展功能:通过扩展为枚举添加了获取图标名称的功能,使代码更整洁

3. MainContainerView:路由的实际应用

import SwiftUI

/// 主容器视图,包含悬浮TabBar
struct MainContainerView: View {
    
    // 获取指定tab的导航路径
    private func getNavigationPath(for tab: MainTab) -> NavigationPath {
        return router.getNavigationPath(for: tab)
    }
    
    /// 创建带有NavigationStack的标签页视图
    private func tabView(_ tab: MainTab) -> some View {
        NavigationStack(path: router.getNavigationPathBinding(for: tab)) {
            switch tab {
            case .home:
                HomeView()
            case .hot:
                HotHomeView()
            case .creation:
                CreationHomeView()
            case .style:
                StyleHomeView()
            case .profile:
                ProfileHomeView()
            }
        }
        .tag(tab)
    }
    
    @StateObject private var appConfigManager = AppConfigManager.shared
    
    @EnvironmentObject private var overlay: GlobalOverlayManager
    @EnvironmentObject private var router: Router
    
    var body: some View {
        if appConfigManager.appConfig != nil {
            ZStack {
                
                // 真正负责页面生命周期的容器
                TabView(selection: $router.selectedTab) {
                    tabView(.home)
                    tabView(.hot)
                    tabView(.creation)
                    tabView(.style)
                    tabView(.profile)
                }
                
                // 你的悬浮TabBar,根据当前选中标签的导航路径长度控制显示
                if isTabBarVisible {
                    VStack {
                        Spacer()
                        FloatingTabBar(selectedTab: $router.selectedTab)
                            .padding(.horizontal, 16)
                            .padding(.bottom, 20)
                    }
                }
                
                // 全局弹框显示
                if let current = overlay.current {
                    
                    // 遮罩
                    Color.black.opacity(0.4)
                        .ignoresSafeArea()
                        .onTapGesture {
                            overlay.dismiss()
                        }
                    
                    switch current {
                    case .login:
                        LoginOverlayView(onClose: {
                            overlay.dismiss()
                        })
                        .transition(.flipFromBottom)
                    }
                }
                
            }
            .animation(.easeInOut(duration: 0.25), value: overlay.current)
        } else {
            // 显示空View
            EmptyView()
                .background(ThemeManager.Background.global)
        }
    }
    
    var isTabBarVisible: Bool {
        return getNavigationPath(for: router.selectedTab).count == 0
    }
}

设计亮点

  • NavigationStack 集成:为每个标签页创建独立的 NavigationStack
  • TabBar 智能显示:根据当前导航路径长度控制 TabBar 的显示/隐藏
  • 环境对象注入:使用 @EnvironmentObject 注入 Router,实现全局访问
  • 动画效果:添加了平滑的过渡动画,提升用户体验

路由管理的实现细节

1. 路径管理机制

路由系统的核心是 NavigationPath 的管理。NavigationPath 是 SwiftUI 4.0+ 引入的类型,它是一个类型擦除的容器,可以存储任意类型的导航目的地。

在我们的实现中:

  • 每个标签页都有自己的 NavigationPath 实例
  • 通过 getNavigationPathBinding 方法获取路径的绑定,用于 NavigationStack
  • 提供了 clearPathclearAllPaths 方法来清空导航路径

2. 标签页切换逻辑

当用户切换标签页时:

  1. router.selectedTab 的值会更新
  2. TabView 会根据新的 selectedTab 显示对应的标签页
  3. 由于每个标签页有独立的 NavigationPath,切换标签不会影响其他标签的导航状态

3. 导航路径的实际使用

在具体的视图中,可以通过以下方式使用路由:

// 在视图中注入 Router
@EnvironmentObject private var router: Router

// 使用全局路由管理进行导航
let currentPath = router.getCurrentNavigationPathBinding()
// 向当前路径添加新页面
currentPath.wrappedValue.append(AppNavigationDestination.materialDetail(material))

// 清空当前标签页的导航路径
router.clearCurrentPath()

4. 导航目的地定义

项目使用 AppNavigationDestination 枚举来定义导航目的地:

import Foundation
import SwiftUI

/// 导航目标枚举
enum AppNavigationDestination: Hashable {
    case accountLogin
    case materialDetail(MaterialListDTOElement)
}

这种方式的优势:

  • 类型安全:使用枚举定义导航目的地,避免了字符串硬编码
  • 参数传递:可以在导航时传递相关数据,如 materialDetail 中的 MaterialListDTOElement
  • 可扩展性:可以轻松添加新的导航目的地

5. NavigationStack 中处理导航目的地

在使用 NavigationStack 时,需要处理导航目的地的显示逻辑。通常在根视图中添加 navigationDestination 修饰符:

NavigationStack(path: router.getNavigationPathBinding(for: tab)) {
    HomeView()
        .navigationDestination(for: AppNavigationDestination.self) { destination in
            switch destination {
            case .accountLogin:
                AccountLoginView()
            case .materialDetail(let material):
                MaterialDetailView(material: material)
            }
        }
}

这样,当我们通过 currentPath.wrappedValue.append(AppNavigationDestination.materialDetail(material)) 导航时,NavigationStack 会自动显示对应的目标视图。

6. 完整导航流程示例

下面是一个完整的导航流程示例,展示从触发导航到显示目标页面的全过程:

// 1. 在视图中注入 Router
@EnvironmentObject private var router: Router

// 2. 定义导航触发事件
Button("查看素材详情") {
    // 3. 获取当前路径绑定
    let currentPath = router.getCurrentNavigationPathBinding()
    // 4. 向路径添加导航目的地
    currentPath.wrappedValue.append(AppNavigationDestination.materialDetail(selectedMaterial))
}

// 5. 在根视图中处理导航目的地
NavigationStack(path: router.getNavigationPathBinding(for: .home)) {
    HomeView()
        .navigationDestination(for: AppNavigationDestination.self) { destination in
            switch destination {
            case .materialDetail(let material):
                MaterialDetailView(material: material)
            default:
                EmptyView()
            }
        }
}

// 6. 从详情页返回
Button("返回") {
    // 清空当前路径,返回根视图
    router.clearCurrentPath()
}

7. 导航路径与 TabBar 显示的关联

MainContainerView 中,通过 isTabBarVisible 计算属性控制 TabBar 的显示:

var isTabBarVisible: Bool {
    return getNavigationPath(for: router.selectedTab).count == 0
}

当导航路径为空时(即处于标签页的根视图),显示 TabBar;当导航路径不为空时(即进入了子页面),隐藏 TabBar,为用户提供更大的内容显示区域。

优势与最佳实践

优势

  1. 清晰的职责分离:路由逻辑与 UI 逻辑分离,使代码更易于维护
  2. 类型安全:使用枚举和类型化的导航路径,减少运行时错误
  3. 状态管理:集中管理路由状态,避免状态分散
  4. 灵活性:可以轻松添加新的标签页和导航目的地
  5. 用户体验:标签页切换时保持各自的导航状态,提升用户体验

最佳实践

  1. 统一的路由入口:所有导航操作都通过 Router 进行,避免直接操作 NavigationPath
  2. 合理的路径清理:在适当的时机清理导航路径,避免内存占用过高
  3. 导航目的地的类型定义:为导航目的地创建明确的类型,提高代码可读性
  4. 错误处理:添加适当的错误处理,确保导航操作的稳定性
  5. 测试:为路由逻辑编写单元测试,确保其正确性

代码优化建议

  1. 导航目的地类型化

    // 建议为每个标签页创建导航目的地枚举
    enum HomeDestination {
        case detail(id: String)
        case search
    }
    
    // 然后在导航时使用
    router.homePath.append(HomeDestination.detail(id: "123"))
    
  2. 添加导航日志

    // 添加导航日志,便于调试和分析用户行为
    func appendToPath(_ value: some Hashable, for tab: MainTab) {
        let path = getNavigationPathBinding(for: tab)
        path.wrappedValue.append(value)
        print("Navigate to \(value) in tab \(tab)")
    }
    
  3. 导航路径持久化

    // 可以考虑在应用进入后台时保存导航状态,在应用启动时恢复
    func saveNavigationState() {
        // 保存导航状态到 UserDefaults 或其他存储
    }
    
    func restoreNavigationState() {
        // 从存储中恢复导航状态
    }
    
  4. 添加路由拦截器

    // 可以添加路由拦截器,用于处理登录验证等场景
    func appendToPath(_ value: some Hashable, for tab: MainTab) {
        if needsAuthentication(for: value) {
            // 显示登录界面
            overlay.present(.login)
        } else {
            let path = getNavigationPathBinding(for: tab)
            path.wrappedValue.append(value)
        }
    }
    

总结

通过以上分析,我们可以看到,一个良好的路由管理架构对于 iOS 应用的重要性。我项目中的路由架构采用了集中式管理、Tab 隔离、响应式设计等原则,通过 Router 类、MainTab 枚举和 MainContainerView 的配合,实现了清晰、灵活、用户友好的导航体验。

这种路由架构不仅适用于当前项目,也可以作为其他 SwiftUI 项目的参考。通过不断优化和扩展,可以构建更加完善的路由系统,为用户提供更加流畅的应用体验。

希望这篇文章能够帮助大家更好地理解和实现 iOS 项目中的路由管理架构。如果你有任何问题或建议,欢迎在评论区留言讨论!

❌