Flutter 智能相册:基于逆地址解析实现「旅行记忆检测」
前言最近在做一个 AI 原生相册项目Memoria / 智能影记。项目本身已经具备相册扫描、事件聚类、AI 打标、地点解析、照片图谱等能力。在一次真机调试中我发现一个很有价值的方向既然照片已经能通过 GPS 和高德逆地址解析得到城市、区县、地点名那么这些信息不应该只用于“显示照片在哪里拍的”还可以进一步用于理解用户的生活轨迹。比如用户平时长期在城市 A 某几天突然连续出现在城市 B 之后又回到城市 A这就很可能是一段旅行、出差、返校、短途游或异地记忆。于是这次给项目新增了一个规则版的旅行记忆检测 TravelMemoryDetector。它不依赖大模型不修改数据库结构也不会阻塞 UI而是利用已有的照片时间、事件聚类和逆地址解析结果检测出“短时间异地停留”的记忆片段。一、为什么要做旅行记忆检测传统相册通常只能做到这张照片拍摄于某城市 这组照片拍摄于某地点但智能相册更应该进一步理解你平时主要在城市 A 但 1 月 18 日 - 1 月 19 日去了城市 B 这段时间拍了 6 张照片 主要地点包括某车站、某商圈这就是从“地点标签”升级到“生活轨迹理解”。这类能力可以用于很多产品场景1. 首页提示发现一次可能的旅行记忆 2. 相册筛选查看旅行照片 3. 故事生成自动生成旅行回忆 4. 照片图谱高亮异地城市照片簇 5. 年度总结生成城市足迹时间线相比单纯展示地点旅行检测更有“智能感”。二、已有数据基础项目里已经有逆地址解析链路。通过高德地图逆地址 API可以从照片或事件中心点得到province city district locationName formattedAddress adcode lat/lon eventId其中PhotoEntity: province city district locationName formattedAddress adcode latitude longitude eventId EventEntity: province city district locationName formattedAddress avgLatitude avgLongitude startTime endTime photoCount photoIds这次没有新增字段也没有重新生成 Isar schema。citycode虽然可以从高德结果里拿到但当前实体里没有持久化字段所以本次检测没有依赖它。这样做的好处是风险低先把能力做成服务接口验证规则有效后再考虑是否扩展数据库结构。三、这次实现的目标本次实现目标如下1. 不修改数据库结构。 2. 不等待所有地址解析完成。 3. 当前有多少已解析的照片/事件就基于多少做检测。 4. 优先按事件聚合减少逐照片误判。 5. 自动识别常驻城市 baseCity。 6. 检测 1 - 14 天的外地连续片段。 7. 根据照片数、事件数、前后是否回到常驻城市计算置信度。 8. 数据库读取保持 async。 9. 规则计算放入 Isolate.run()避免阻塞 UI。 10. 增加开发者调试入口方便真机验证。 11. 脱敏逆地址日志不再打印完整高德 JSON、经纬度和详细地址。四、整体架构这次新增了两个核心类TravelMemoryService 负责从 Isar 异步读取事件和照片数据 转换成轻量快照 调用 isolate 进行规则计算 对外提供 detectRecentTravelMemories() 和 buildDebugSummary() TravelMemoryDetector 纯 Dart 规则检测核心 不依赖 Flutter UI 不依赖 Isar 实体 负责构建每日地点观察值、识别常驻城市、检测旅行片段整体流程如下Isar 数据库 ↓ 读取最近窗口内 EventEntity / PhotoEntity ↓ 转换为 TravelEventSnapshot / TravelPhotoSnapshot ↓ Isolate.run() ↓ TravelMemoryDetector.detectFromSnapshots() ↓ 输出 TravelMemoryCandidate这样 UI 层不会死等地址解析也不会被规则计算卡住。五、为什么要用轻量 SnapshotDart isolate 之间传数据有要求不能随便把复杂对象传进去。Isar Entity、BuildContext、Widget、Image 这些对象都不适合直接传入 isolate。因此这里引入轻量快照对象class TravelEventSnapshot { const TravelEventSnapshot({ required this.id, required this.startTime, required this.endTime, required this.photoCount, this.province, this.city, this.district, this.locationName, }); final int id; final int startTime; final int endTime; final int photoCount; final String? province; final String? city; final String? district; final String? locationName; }照片也类似class TravelPhotoSnapshot { const TravelPhotoSnapshot({ required this.id, required this.timestamp, this.eventId, this.province, this.city, this.district, this.locationName, this.adcode, }); final int id; final int timestamp; final int? eventId; final String? province; final String? city; final String? district; final String? locationName; final String? adcode; }这样 isolate 里只处理简单数据避免数据库对象跨线程问题。六、不再全量扫描只读取最近时间窗口第一版最直接的写法是final events await isar .collectionEventEntity() .where() .sortByStartTime() .findAll(); final photos await isar .collectionPhotoEntity() .where() .sortByTimestamp() .findAll();这在照片数量较少时没问题但如果用户有几千甚至几万张照片就会带来 I/O 和内存压力。所以后续优化为1. 先找最新 event/photo 时间 2. 根据 lookbackDays 计算窗口开始时间 3. 只读取最近 90 天或 180 天数据 4. 默认检测最近 90 天调试入口使用 180 天。伪代码如下FutureListTravelMemoryCandidate detectRecentTravelMemories({ int lookbackDays 90, }) async { final latestTimestamp await _findLatestTimestamp(); if (latestTimestamp null) { return const []; } final windowStart _calculateWindowStart( latestTimestamp, lookbackDays, ); final events await _loadEventsAfter(windowStart); final photos await _loadPhotosAfter(windowStart); final eventSnapshots events .map(TravelEventSnapshot.fromEntity) .toList(growable: false); final photoSnapshots photos .map(TravelPhotoSnapshot.fromEntity) .toList(growable: false); return Isolate.run( () TravelMemoryDetector.detectFromSnapshots( events: eventSnapshots, photos: photoSnapshots, lookbackDays: lookbackDays, ), ); }这一步非常关键。异步只能避免阻塞等待但如果你异步读了全量数据依然可能造成性能压力。限制时间窗口后这个服务才更适合长期运行。七、按事件优先聚合减少误判旅行检测不应该直接逐照片判断。因为单张照片可能存在1. GPS 漂移 2. 地点解析不准 3. 用户转发或保存了异地照片 4. 一天内跨多个区县 5. 少量孤立照片不代表旅行。所以这次采用“事件优先”的策略。事件本身已经是项目里的时空聚类结果。一个 EventEntity 通常表示某段时间内同一批相关照片因此比单张照片更稳定。核心思路1. 先把有 eventId 的照片归到事件下 2. 事件优先使用自身 city/district/locationName 3. 如果事件缺少 city则从事件内照片取最高频城市 4. 没有事件归属的照片再按天聚合 5. 最后统一转换为 TravelDayObservation。每日观察值结构类似class TravelDayObservation { const TravelDayObservation({ required this.day, required this.city, required this.photoCount, required this.eventIds, required this.locationNames, this.district, this.adcode, }); final TravelDay day; final String city; final String? district; final String? adcode; final int photoCount; final Setint eventIds; final ListString locationNames; }八、识别常驻城市 baseCity要判断“旅行”首先要知道“平时在哪里”。因此需要识别常驻城市baseCity 最近窗口内出现天数最多的城市这里没有用照片数最多作为唯一指标因为照片数可能被一次旅行或一次活动放大。按“天数”统计更接近生活常驻状态。伪代码String? detectBaseCity(ListTravelDayObservation observations) { final cityDayCounts String, int{}; for (final observation in observations) { cityDayCounts[observation.city] (cityDayCounts[observation.city] ?? 0) 1; } final ranked cityDayCounts.entries.toList() ..sort((a, b) b.value.compareTo(a.value)); if (ranked.isEmpty) { return null; } return ranked.first.key; }例如城市 A45 天 城市 B2 天 城市 C1 天那么城市 A 就是常驻城市。城市 B 和城市 C 的短期连续片段就可能是旅行候选。九、检测外地连续片段有了 baseCity 后检测逻辑就很清晰1. 按日期排序 2. 取每天的主城市 3. 如果当天城市 ! baseCity则加入 travelDays 4. 把城市相同且日期连续的外地天合并为 segment 5. segment 长度在 1 - 14 天之间才算候选 6. photoCount 3 或 eventCount 1 才保留。核心规则class TravelMemoryDetector { static const int minTripDays 1; static const int maxTripDays 14; static const int minPhotosForTrip 3; }为什么最大 14 天因为这次目标是识别“旅行记忆 / 短期异地停留”不是搬家、实习、长期驻留。超过 14 天的外地连续片段暂时不作为旅行处理避免误判。十、置信度评分为了区分“高置信旅行”和“可能旅行”检测器会给每个候选片段打分。评分考虑因素包括1. 是否不同于 baseCity 2. 连续天数是否合理 3. 照片数量是否足够 4. 是否有事件聚类支撑 5. 旅行前是否出现过 baseCity 6. 旅行后是否回到 baseCity 7. 是否有明确地点名。示例double scoreTravelSegment(...) { var score 0.0; if (city ! baseCity) { score 0.35; } if (dayCount 1 dayCount 7) { score 0.20; } if (photoCount 5) { score 0.15; } if (hasBaseCityBefore) { score 0.15; } if (hasBaseCityAfter) { score 0.15; } return score.clamp(0.0, 1.0); }最终输出结构class TravelMemoryCandidate { const TravelMemoryCandidate({ required this.city, required this.startDay, required this.endDay, required this.score, required this.photoCount, required this.eventIds, required this.mainLocationNames, }); final String city; final TravelDay startDay; final TravelDay endDay; final double score; final int photoCount; final Setint eventIds; final ListString mainLocationNames; }十一、调试入口开发者设置里触发检测这次还增加了一个轻量开发者入口我的 - 开发者设置 - 旅行记忆检测点击后调用TravelMemoryService().buildDebugSummary(lookbackDays: 180)然后用 Dialog 展示前 5 个候选片段。真机结果类似TravelMemoryDetector: 2 candidate(s) - 某城市A 2026-01-18..2026-01-19 score0.76 photos6 events1 locations某区县/某车站 - 某城市B 2026-01-26..2026-01-27 score0.64 photos6 events1 locations某区县/某小区/某地点这说明检测器已经能从真实相册中识别出短期异地停留片段。需要注意这个入口目前仍然是 debug 性质正式 UI 应该把文案产品化例如发现 2 段可能的旅行记忆 1. 1月18日 - 1月19日 · 某城市A 置信度较高 照片6 张 主要地点某车站 2. 1月26日 - 1月27日 · 某城市B 置信度可能 照片6 张 主要地点某小区 / 某地点十二、日志脱敏不再打印完整高德返回值这次另一个重要改动是日志脱敏。原来逆地址解析时会打印完整高德返回 JSON里面包含经纬度 完整详细地址 道路 POI AOI 区县 城市 adcode 周边地点这在调试阶段很方便但风险很高。如果日志被上传到 CSDN、GitHub issue、交流群或截图里会暴露用户轨迹。因此这次把日志改成只打印粗粒度状态print( 高德逆地址响应: status${body[status] ?? -} hasRegeocode${body[regeocode] is MapString, dynamic} extensions$extensions, );事件地址解析成功时只保留print( 事件地址解析成功: id${event.id} city${city ?? -} district${district ?? -} adcode${adcode ?? -} citycode${citycode ?? -}, );照片地址解析成功时只保留print( 照片地址解析成功: id${photo.id} city${city ?? -} district${district ?? -} adcode${adcode ?? -}, );不再打印1. 完整高德 JSON 2. 原始经纬度 3. 完整 formattedAddress 4. POI 详细名称 5. 道路和门牌号。对于智能相册项目来说日志脱敏不是可选项而是必须项。十三、测试覆盖本次新增了travel_memory_detector_test.dart覆盖了三个核心场景。1. 能识别短途外地旅行常驻城市 A 短期连续出现在城市 B 照片数足够 前后有城市 A 应识别为旅行候选2. 超过 14 天不算旅行连续 20 天都在城市 B 更像长期驻留不作为旅行候选3. 没有事件也可以用照片识别部分照片没有 eventId 但同一天/连续几天照片数量足够 且城市不同于 baseCity 仍然可以作为候选验证命令flutter test test\service\travel_memory_detector_test.dart结果通过。同时项目总测试集合也通过powershell -ExecutionPolicy Bypass -File tool\run_test_suite.ps1稳定测试数量从之前的 39 个增加到 42 个全部通过。十四、真机验证结果本次在 Android 真机上进行了调试。App 成功构建、安装、启动并通过开发者入口触发旅行检测。真机 Dialog 返回了 2 个旅行候选片段TravelMemoryDetector: 2 candidate(s) - 某城市A 2026-01-18..2026-01-19 score0.76 photos6 events1 - 某城市B 2026-01-26..2026-01-27 score0.64 photos6 events1这说明1. 数据库读取正常 2. 时间窗口过滤正常 3. isolate 规则计算正常 4. 真实相册数据可以产生候选结果 5. UI 没有因为旅行检测而卡死 6. 开发者入口可用于后续调试。调试日志里仍然有 vivo 系统的 SELinux 噪声avc: denied { ioctl } for path/proc/fas/render这类日志更像厂商 ROM 权限噪声不是本次旅行检测逻辑导致的错误。只要没有 Flutter 红屏、Dart exception、进程退出就可以暂时忽略。十五、这次实现的核心经验1. 逆地址解析不只是为了显示地点很多相册项目拿到地址后只做了照片地点某城市某区但更有价值的是进一步推理用户平时在哪里 什么时候去了外地 去了几天 拍了多少照片 前后是否回到常驻城市这才是智能相册应该做的事。2. 先做规则不急着上大模型旅行检测这个问题用规则就能拿到不错的 V1 效果。不需要一开始就让 LLM 参与。规则版的优势是1. 可解释 2. 成本低 3. 不需要联网 4. 不依赖模型输出稳定性 5. 方便写单元测试 6. 易于逐步调参。后续可以让 LLM 做文案生成而不是让 LLM 做底层检测。3. 不要把重计算放在 UI isolateFlutter 中async/await并不等于多线程。如果只是异步等待 I/O确实不会阻塞 UI但如果在主 isolate 里做大量排序、聚合、规则计算仍然可能造成卡顿。这次采用数据库读取async 数据转换轻量 snapshot 规则计算Isolate.run() UI 展示Dialog这是比较稳的结构。4. 日志脱敏必须尽早做调试地址解析时完整 JSON 很诱人因为信息非常全。但这类日志包含用户隐私轨迹必须尽早脱敏。尤其是要避免打印经纬度 完整地址 门牌号 小区名 学校名 车站名 原始 API 响应公开博客、截图、issue 中更应该使用“某城市 / 某地点”替代。十六、后续优化方向当前只是 V1 规则检测后续还可以继续升级。1. 过滤行政区作为 locationName现在候选里有时会出现locations某区县/某车站区县更像行政区域不是具体地点。后续可以过滤掉province city district adcode优先展示 POI、AOI、建筑物、小区、车站、景点等更具体的地点。2. 正式 UI 产品化现在是开发者 Dialog后续可以做成正式卡片发现一次可能的旅行记忆 1月18日 - 1月19日 · 某城市 共 6 张照片 主要地点某车站 [查看照片]3. 接入首页和故事生成旅行候选可以进入故事生成模块标题某城市两日记忆 时间1月18日 - 1月19日 照片6 张 地点某车站、某商圈然后生成旅行回忆标题 短视频脚本 朋友圈文案 相册章节摘要4. 与照片图谱结合在照片图谱中新增“旅行模式”常驻城市照片弱显示 旅行城市照片高亮 同一次旅行形成照片簇 城市切换用弧线连接这样就能把地点检测、旅行识别和视觉图谱结合起来。5. 加入更多信号后续可以加入1. 距离常驻城市的地理距离 2. 是否跨省 3. 是否有车站、机场、酒店、景点 POI 4. 拍摄时间密度 5. 是否有连续多天夜间照片 6. 是否和节假日重合 7. 是否出现“旅行、酒店、车票”等 OCR 关键词。这些可以让旅行检测更准确。结语这次实现的「旅行记忆检测」并不是一个复杂模型而是一个非常实用的规则系统。它利用已有的照片时间、事件聚类和逆地址解析结果完成了一个从“地点展示”到“轨迹理解”的升级不是只知道“照片拍在某城市” 而是知道“你平时在城市 A但这几天去了城市 B”本次实现中我尽量保持了几个原则1. 不改数据库 schema 2. 不阻塞 UI 3. 不等待所有地址解析完成 4. 优先使用事件聚合 5. 规则计算放入 isolate 6. 日志脱敏 7. 增加单元测试 8. 先做开发者入口验证真实数据。最终真机上成功识别出 2 段候选旅行记忆说明这个方向是成立的。对于智能相册来说这类能力比单纯堆 UI 更重要。因为它让 App 开始真正理解用户的生活而不只是存储用户的照片。