欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net


一、本篇速览

  1. 浏览历史:去重 + 分类 + 友好时间,100 条上限
  2. 设置页:深色模式、分级清缓存、存储可视化
  3. 统一文件存储:JSON + 内存双保险,HarmonyOS 路径全兼容
  4. 体验加分:Tab 切换、二次确认、空状态、下拉刷新

二、需求分析

2.1 业务价值

  • 提升留存:用户可快速找回「刚才看过」的游戏,减少重复搜索
  • 增强掌控:分级清理让用户「按需删、不心疼」,降低卸载率
  • 品牌一致:深色模式跟随系统,与 HarmonyOS 原生体验无缝衔接

2.2 用户体验

  • 即时反馈:点击游戏立即记录,1 ms 内完成去重插顶,无感知延迟
  • 容错设计:文件损坏自动降级,防止用户手动删改后崩溃
  • 多端一致:同一套代码在 Android/iOS/HarmonyOS 三端行为一致,减少测试成本

2.3 数据安全

  • 本地加密(可选):当记录含敏感字段时,可引入 encrypted_box 加密,密钥存于 HarmonyOS Keystore,实现「本地零明文」
  • 云端备份:后续接入华为云 OBS,换机时一键拉取

三、核心模块速查

功能 关键类/方法 一句话总结
浏览历史 BrowseHistoryService.addHistory() 点击即写,自动去重,100 条上限
分类管理 _isClassic(String name) 白名单 在线/经典两栏,旧数据零改造
主题切换 SettingsService.themeModeNotifier ValueNotifier 实时刷,JSON 落盘
分级清理 clearClassic() / clearOnline() 返回删除条数,二次确认
存储可视化 getStorageInfo() 条数 + 占用大小,下拉刷新

好的,这次我们聚焦于具体的代码片段,并围绕每个功能点可能遇到的问题及其解决方案来展开。以下是针对浏览历史和设置模块的深度技术解析。


四、浏览历史与设置模块:代码实现与坑点规避

1、浏览历史模块:数据一致性与去重算法

1.1 核心数据结构与初始化
class BrowseHistoryService {
  // 内存缓存 - 使用双向队列方便头部插入和尾部删除
  final List<BrowseHistory> _history = [];
  
  // 文件路径缓存,避免重复获取
  late final File _historyFile;
  
  // 初始化时加载数据
  BrowseHistoryService() {
    _initStorage();
  }
  
  Future<void> _initStorage() async {
    try {
      final dir = await getApplicationDocumentsDirectory();
      _historyFile = File('${dir.path}/browse_history.json');
      
      if (await _historyFile.exists()) {
        final jsonStr = await _historyFile.readAsString();
        final List<dynamic> jsonList = jsonDecode(jsonStr);
        
        // 反序列化并排序(确保按时间倒序)
        _history.clear();
        _history.addAll(jsonList
            .map((e) => BrowseHistory.fromJson(e))
            .toList()
          ..sort((a, b) => b.browseTime.compareTo(a.browseTime)));
      }
    } catch (e) {
      print('初始化历史记录失败: $e');
      // 遇到错误时,内存保持空列表,不抛出异常
    }
  }
}

遇到的问题与解决

问题 现象 解决方案
JSON解析异常 偶现应用启动闪退 增加try-catch,解析失败时重置为空列表,而不是崩溃
文件损坏 用户手动修改了JSON文件,格式错误 读取时校验JSON格式,异常则备份原文件后创建新文件
并发写入 短时间内多次添加记录,文件写入冲突 引入async锁或使用compute隔离写入操作
1.2 去重与插入的核心算法
Future<void> addHistory(BrowseHistory item) async {
  // 问题1:如何高效去重?
  // 使用反向遍历避免索引错乱
  for (int i = _history.length - 1; i >= 0; i--) {
    if (_history[i].gameId == item.gameId) {
      _history.removeAt(i);
      break; // 同ID只存在一条,找到即可停止
    }
  }
  
  // 插入到头部(最新记录)
  _history.insert(0, item);
  
  // 问题2:如何优雅地限制上限?
  if (_history.length > 100) {
    // 保留前100条,删除尾部
    _history.removeRange(100, _history.length);
  }
  
  // 问题3:频繁写入的性能问题
  // 方案:防抖写入 - 仅当用户停止操作300ms后才真正写文件
  _debouncedSave();
}

// 防抖实现
Timer? _saveTimer;
void _debouncedSave() {
  _saveTimer?.cancel();
  _saveTimer = Timer(const Duration(milliseconds: 300), () {
    _saveToFile();
  });
}

Future<void> _saveToFile() async {
  try {
    final jsonStr = jsonEncode(_history.map((e) => e.toJson()).toList());
    await _historyFile.writeAsString(jsonStr);
  } catch (e) {
    print('保存历史记录失败: $e');
    // 可考虑降级方案:保存到临时缓存,下次启动时重试
  }
}

深入解析

  • 为什么用反向遍历:正向遍历删除元素会导致索引错乱,反向遍历安全且高效
  • 防抖写入的价值:假设用户连续浏览10个游戏,会触发10次写入。防抖后,300ms内只写最后一次,IO次数减少90%
  • 上限管理策略:100条是经验值,可根据实际测试调整。注意删除尾部时,removeRange比循环removeLast更高效
1.3 分类获取的优化
List<BrowseHistory> getByCategory(String category) {
  // 问题:每次获取都遍历全部历史,频繁调用时性能差
  // 方案1:缓存分类结果(需在添加记录时更新缓存)
  
  // 方案2:使用where的懒加载,仅当真正使用时才遍历
  return _history.where((h) => h.category == category).toList();
  
  // 进阶:如果历史记录超过1000条,可考虑建立索引Map
  // final _categoryIndex = <String, List<BrowseHistory>>{};
  // 添加时同步更新索引
}

// 友好时间格式化
String formatBrowseTime(DateTime time) {
  final now = DateTime.now();
  final difference = now.difference(time);
  
  if (difference.inMinutes < 1) {
    return '刚刚';
  } else if (difference.inHours < 1) {
    return '${difference.inMinutes}分钟前';
  } else if (difference.inDays < 1) {
    return '今天 ${DateFormat.Hm().format(time)}';
  } else if (difference.inDays == 1) {
    return '昨天 ${DateFormat.Hm().format(time)}';
  } else {
    return DateFormat.yMd().add_jm().format(time);
  }
}

踩坑记录

  • 时区问题:存储时统一使用UTC时间,展示时转换为本地时间,避免跨时区用户看到错误时间
  • 分类字段缺失:早期版本未存储category,导致旧数据无法分类。解决方案:添加数据迁移脚本,根据gameId补查category

2、设置模块:状态持久化与即时反馈

2.1 基于ValueNotifier的主题管理
class SettingsService {
  // 单例模式
  static final SettingsService _instance = SettingsService._internal();
  factory SettingsService() => _instance;
  SettingsService._internal();
  
  // 主题状态 - 使用ValueNotifier实现响应式
  final themeModeNotifier = ValueNotifier<ThemeMode>(ThemeMode.system);
  
  // 缓存大小状态 - 用于UI实时更新
  final cacheSizeNotifier = ValueNotifier<String>('计算中...');
  
  // 初始化
  Future<void> loadSettings() async {
    try {
      final file = await _getSettingsFile();
      if (await file.exists()) {
        final jsonStr = await file.readAsString();
        final Map<String, dynamic> json = jsonDecode(jsonStr);
        
        // 解析主题
        if (json['themeMode'] != null) {
          themeModeNotifier.value = ThemeMode.values[json['themeMode']];
        }
        
        // 加载完成后刷新缓存大小
        _updateCacheSize();
      }
    } catch (e) {
      print('加载设置失败: $e, 使用默认值');
      // 使用默认值,不崩溃
    }
  }
  
  // 更新主题
  Future<void> updateTheme(ThemeMode mode) async {
    if (themeModeNotifier.value == mode) return;
    
    themeModeNotifier.value = mode;
    await _saveSettings();
  }
  
  Future<void> _saveSettings() async {
    try {
      final file = await _getSettingsFile();
      final json = {
        'themeMode': themeModeNotifier.value.index,
      };
      await file.writeAsString(jsonEncode(json));
    } catch (e) {
      print('保存设置失败: $e');
      // 可考虑备用存储方案
    }
  }
}

遇到的问题

  • ValueNotifier的监听泄漏:在StatefulWidget中监听后,务必在dispose中移除监听
  • 主题切换的闪烁:首次启动时,默认ThemeMode.system可能延迟加载,导致先显示亮色再切换为暗色。解决方案:在main.dart中异步加载设置后再运行app
2.2 分级清理的实现细节
class SettingsService {
  // 按分类清理历史
  Future<int> clearHistoryByCategory(String category) async {
    // 问题:如何让用户感知清理了多少条?
    
    // 获取清理前的数量
    final historyService = BrowseHistoryService();
    final beforeCount = historyService.getByCategory(category).length;
    
    if (beforeCount == 0) {
      return 0; // 无需清理
    }
    
    // 执行清理 - 在历史服务中实现具体逻辑
    final removedCount = await historyService.clearByCategory(category);
    
    // 更新缓存大小显示
    await _updateCacheSize();
    
    // 返回删除数量供UI显示
    return removedCount;
  }
  
  // 计算缓存大小
  Future<void> _updateCacheSize() async {
    try {
      // 获取历史文件大小
      final historyFile = await _getHistoryFile();
      int totalBytes = 0;
      
      if (await historyFile.exists()) {
        final stat = await historyFile.stat();
        totalBytes += stat.size;
      }
      
      // 可添加图片缓存、日志文件等
      
      // 格式化显示
      String sizeStr;
      if (totalBytes < 1024) {
        sizeStr = '$totalBytes B';
      } else if (totalBytes < 1024 * 1024) {
        sizeStr = '${(totalBytes / 1024).toStringAsFixed(1)} KB';
      } else {
        sizeStr = '${(totalBytes / 1024 / 1024).toStringAsFixed(1)} MB';
      }
      
      cacheSizeNotifier.value = sizeStr;
    } catch (e) {
      cacheSizeNotifier.value = '未知';
      print('计算缓存大小失败: $e');
    }
  }
}

坑点与解决

  • 异步更新UIcacheSizeNotifier.value的更新必须在主线程,计算过程可在isolate中执行避免卡顿
  • 文件大小计算的精度:频繁调用stat()可能影响性能,可考虑缓存结果30秒
  • 清理的原子性:如果清理过程中应用被杀,可能导致状态不一致。可考虑使用事务性写入
2.3 UI层的监听与响应
// 主题切换下拉菜单
DropdownButton<ThemeMode>(
  value: SettingsService().themeModeNotifier.value,
  items: const [
    DropdownMenuItem(value: ThemeMode.system, child: Text('跟随系统')),
    DropdownMenuItem(value: ThemeMode.light, child: Text('浅色模式')),
    DropdownMenuItem(value: ThemeMode.dark, child: Text('深色模式')),
  ],
  onChanged: (ThemeMode? mode) {
    if (mode != null) {
      SettingsService().updateTheme(mode);
    }
  },
)

// 清理按钮的二次确认
ElevatedButton(
  onPressed: () async {
    // 显示二次确认对话框
    final confirm = await showDialog<bool>(
      context: context,
      builder: (ctx) => AlertDialog(
        title: const Text('确认清理'),
        content: const Text('确定要清空在线游戏的历史记录吗?'),
        actions: [
          TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
          TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')),
        ],
      ),
    );
    
    if (confirm == true) {
      // 执行清理
      final removed = await SettingsService().clearHistoryByCategory('online');
      
      if (removed > 0 && mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('已清理 $removed 条记录')),
        );
      }
    }
  },
  child: const Text('清空在线历史'),
)

// 实时监听缓存大小变化
ValueListenableBuilder<String>(
  valueListenable: SettingsService().cacheSizeNotifier,
  builder: (context, size, child) {
    return ListTile(
      title: const Text('缓存占用'),
      trailing: Text(size),
      onTap: () {
        // 点击可刷新大小
        SettingsService().updateCacheSize();
      },
    );
  },
)

3、OpenHarmony平台专属问题

3.1 文件路径问题
// 错误的做法
final file = File('/data/data/com.example.app/history.json'); // 硬编码路径

// 正确的做法
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/history.json');

// HarmonyOS特殊处理:如果上述方法失败
if (dir.path.isEmpty) {
  // 回退路径:HarmonyOS标准沙箱路径
  final fallback = '/data/storage/el2/base/haps/entry/files';
  file = File('$fallback/history.json');
}

遇到的真实问题:在部分HarmonyOS版本上,getApplicationDocumentsDirectory()返回空字符串。解决方案是增加回退逻辑。

3.2 权限问题
// 检查是否有文件读写权限
bool _checkStoragePermission() async {
  // HarmonyOS需要检查ohos.permission.READ_MEDIA等
  // 如果使用path_provider操作应用私有目录,通常不需要权限
  // 但如果用户手动选择外部文件,则需要权限
  return true; // 简化处理,实际需根据情况判断
}

经验:操作应用私有目录(如getApplicationDocumentsDirectory()返回的路径)不需要申请存储权限,这是新手最容易困惑的点。

3.3 系统主题同步
// 监听系统深色模式变化

void initState() {
  super.initState();
  _platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
  
  // 添加监听
  WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged = () {
    if (SettingsService().themeModeNotifier.value == ThemeMode.system) {
      setState(() {
        _platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
      });
    }
  };
}

4、性能优化与监控

// 监控文件写入性能
Future<void> _saveWithMetrics() async {
  final stopwatch = Stopwatch()..start();
  
  await _saveToFile();
  
  stopwatch.stop();
  if (stopwatch.elapsedMilliseconds > 100) {
    print('警告:文件写入耗时 ${stopwatch.elapsedMilliseconds}ms');
    // 可上报监控平台
  }
}

// 大JSON的分批处理
Future<void> _saveLargeList(List<BrowseHistory> list) async {
  const batchSize = 500;
  final file = await _getHistoryFile();
  final sink = file.openWrite();
  
  sink.write('[');
  for (int i = 0; i < list.length; i += batchSize) {
    final batch = list.skip(i).take(batchSize);
    final jsonBatch = batch.map((e) => jsonEncode(e.toJson())).join(',');
    sink.write(jsonBatch);
    if (i + batchSize < list.length) {
      sink.write(',');
    }
    await sink.flush(); // 分批写入,避免内存暴涨
  }
  sink.write(']');
  await sink.close();
}

5、总结:关键问题清单

功能点 常见问题 解决方案
历史去重 重复记录、索引错乱 反向遍历删除 + 防抖写入
文件存储 并发写入冲突、文件损坏 异步锁 + 异常捕获 + 备份恢复
主题切换 启动闪烁、系统栏不同步 预加载设置 + 监听系统变化
缓存计算 主线程卡顿、显示不更新 isolate计算 + ValueNotifier广播
平台适配 路径错误、权限困惑 使用标准插件 + 回退机制

以上代码片段和问题解决方案均来自实际开发经验,希望能帮助你更深入地理解这两个模块的实现细节。如果需要对某个具体问题深入探讨,欢迎继续交流。

五、目录

完整工程结构

lib/
├── models/
│   ├── game.dart
│   └── browse_history.dart
├── services/
│   ├── settings_service.dart
│   └── browse_history_service.dart
└── pages/
    ├── settings_page.dart
│   └── browse_history_page.dart

六、后续可扩展方向

  1. 云端同步
    将本地 JSON 上传至华为云 OBS,换机时通过账号体系一键拉取,实现「跨设备浏览历史」

  2. 搜索与排序
    在历史页顶部增加 SearchBar,支持按标题模糊匹配;提供「时间/标题」排序切换,使用 List.sort 即可完成本地排序,无需后端。

  3. 统计面板
    fl_chart 展示「每日浏览次数」「最常浏览游戏 TOP10」,数据直接源于本地 JSON,无需联网,助力运营快速洞察用户偏好。

  4. 精细化清理
    新增「保留最近 30 天」「仅删除低分记录」等策略,通过 where 过滤后批量删除,减少用户误删,提升掌控感。

  5. 本地加密(可选)
    当记录包含敏感字段时,可将 JSON 写入前使用 hive + encrypted_box 加密,密钥存于 HarmonyOS Keystore,实现「本地零明文」。


七、结语

补齐了 HarmonyOS 游戏应用的「设置-历史」双核心模块:

  • 浏览历史:去重、分类、友好时间、一键清除
  • 设置页:深色模式、分级清缓存、存储可视化
Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐