news 2026/9/8 7:58:17

Flutter for OpenHarmony音乐播放器:收藏功能全链路实现与踩坑记录

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Flutter for OpenHarmony音乐播放器:收藏功能全链路实现与踩坑记录

音乐App里如果只能留三个页面,播放页、歌单页之外,我一定会留“我喜欢的音乐”。这个功能看似简单,不就是点个心形、存个列表嘛,但真做起来牵扯到数据持久化、全局状态同步、列表展示、播放队列联动这一整套链路。今天这篇是这个Flutter for OpenHarmony音乐播放器系列的第24篇,我把做“我喜欢的音乐”这个功能时踩过的坑和最终的实现方案完整过一遍。如果你没看前面的连载也没关系,这篇涉及的代码模块相对独立,收藏功能的设计思路你可以直接拿去用。

做这个功能之前我原本也是想“两小时搞定”,结果真正落地时发现小细节一个比一个磨人:收藏状态在列表页和播放页怎么同步?重启App之后数据还在不在?手滑删除了能不能找回?这些才是用户真正感知到的体验。下面我会从数据层、状态层、UI层到常见问题排查,完整展开。

1. 功能定位与整体设计

1.1 “我喜欢的音乐”到底要做成什么样

先别急着写代码,我们花两分钟把功能边界画清楚。音乐App里“我喜欢的音乐”本质上是一个用户私有歌单,但它和普通歌单有一个关键区别:它是全局的、隐式的、高频操作的。用户在任何歌曲列表页、播放页都可以随时把歌扔进去,它的入口也必须无处不在。

我这个版本的“我喜欢的音乐”功能清单如下:

  • 歌曲列表页、播放页展示当前歌曲的喜欢状态。
  • 点击心形按钮,切换喜欢/取消喜欢,交互要有即时反馈。
  • “我喜欢的音乐”页面展示所有已收藏歌曲,按收藏时间倒序排列。
  • 点击列表条目,立即播放该歌曲,并把这个列表作为后续播放队列。
  • 支持滑动删除单首歌曲,也支持清空整个列表。
  • App重启后收藏数据不丢失。

交互边界也很重要。我做设计时明确了几条规则:

  • 取消喜欢不弹二次确认框。用户点心形就是一瞬间的冲动操作,弹框反而打断节奏。但滑动删除属于“大动作”,我会用Dismissible的confirmDismiss做一个轻量确认。
  • 收藏列表里的歌曲信息存的是“快照”。即使歌曲在云端已经下架,本地依然能显示名称和歌手,用户仍然可以播放本地缓存或看到一条灰置记录。
  • 本期不支持排序、分组和搜索,只按收藏时间倒序。这些能力留到后面的迭代,不在一期里堆功能。

1.2 技术方案选型的思考

技术选型这件事,我结合前面23篇的经验给出一个对比。

“收藏列表”存储选型有三个常见候选:shared_preferences、sqflite、Hive。

方案类型平台依赖OpenHarmony适配难度适合场景
shared_preferences键值对平台通道中等,需要路径适配少量配置项
sqflite关系型数据库平台通道较高,依赖原生SQLite复杂SQL查询
Hive轻量NoSQL几乎纯Dart结构化轻量数据

我的结论是用Hive。原因有三:

第一,收藏列表这种数据,结构简单、单条记录不大、总量量级在几千条以内,根本用不着关系型数据库的JOIN、索引这些能力。Hive序列化后直接写文件,读几百条数据耗时在毫秒级,体验完全够用。

第二,Hive的核心实现是纯Dart,平台通道只涉及文件目录获取。在OpenHarmony上跑的时候,只要能把应用可写目录传给Hive初始化,剩下的读写逻辑不需要关心宿主系统是Android、iOS还是OpenHarmony。这一点对做多端适配的人来说省了太多事。

第三,Hive不需要为每个实体类手写建表SQL,定义好adapter之后就是put、get、delete三板斧,开发效率高。

状态管理我选的是ChangeNotifier + Provider。Flutter圈现在Riverpod、Bloc都挺流行,但“我喜欢的音乐”本质上是一份全局共享的集合数据,派生的UI主要是“某个id是否喜欢”和“收藏列表”两处。ChangeNotifier完全能表达清楚,没有复杂的异步事件流,没必要引入重量级状态管理。选择状态管理的第一原则是匹配业务复杂度,而不是追新。

整个数据流是单向的:界面事件触发LikeController的方法,方法先修改内存状态并通知UI刷新,然后异步把结果写进Hive。UI不直接操作数据库Box,避免出现在这里改了、那里没改的失控局面。

2. 数据层:收藏记录怎么存

2.1 收藏数据模型设计

先定义模型。这里有一个关键决策:收藏记录里到底存什么?

一开始我偷懒只存了songId,结果发现不靠谱。用户打开“我喜欢的音乐”页面,如果每一首歌都要拿id去远端接口重新拉歌曲信息,遇到网络慢、接口挂、歌曲下架,列表就是一片空白。所以在本地存一份歌曲信息快照是必要的。

看一下我的LikeSong模型:

class LikeSong { final String songId; final String songName; final String artist; final String album; final int duration; // 单位:秒 final String coverUrl; // 封面图地址,可为空 final int likedAt; // 收藏时间戳,毫秒 LikeSong({ required this.songId, required this.songName, required this.artist, required this.album, required this.duration, required this.likedAt, this.coverUrl = '', }); Map<String, dynamic> toJson() { return { 'songId': songId, 'songName': songName, 'artist': artist, 'album': album, 'duration': duration, 'coverUrl': coverUrl, 'likedAt': likedAt, }; } factory LikeSong.fromJson(Map<String, dynamic> json) { return LikeSong( songId: json['songId'] as String, songName: json['songName'] as String, artist: json['artist'] as String, album: json['album'] as String, duration: json['duration'] as int, coverUrl: json['coverUrl'] as String? ?? '', likedAt: json['likedAt'] as int? ?? 0, ); } }

注意demo中的coverUrl加了一个兜底空串,likedAt做了空值保护。这种兼容处理在你后续升级字段时非常有用,老用户的本地产物可能缺少新字段,读不出来整个Box崩溃就尴尬了。

likedAt有一个微妙的作用:排序。收藏列表我默认按时间倒序,用户刚喜欢的歌出现在最顶部,符合直觉;而且这样完全不需要用户手动拖拽排序,产品逻辑简单一大截。

2.2 Hive初始化与存取封装

接下来是数据访问层。我不建议业务代码直接操作Box,而是包一层DAO,这样做有两个好处:第一,后续如果要把本地存储替换成后端同步,只需要改DAO内部实现;第二,DAO可以统一处理Box的打开、异常捕获、字段兼容,业务侧只管调用。

Hive在OpenHarmony上的初始化,核心问题是拿到一个可写的应用目录。如果你的Flutter for OpenHarmony壳工程里已经把path_provider适配好了,直接用getApplicationDocumentsDirectory()即可;如果还没适配,可以通过平台通道拿宿主沙箱路径再传给Hive.init。

import 'package:hive/hive.dart'; import 'package:path_provider/path_provider.dart'; class LikedSongsDao { static const String _boxName = 'likedSongsBox'; late Box _box; Future<void> init() async { final dir = await getApplicationDocumentsDirectory(); Hive.init(dir.path); _box = await Hive.openBox<Map>(_boxName); } Future<void> addLike(LikeSong song) async { await _box.put(song.songId, song.toJson()); } Future<void> removeLike(String songId) async { await _box.delete(songId); } LikeSong? getLike(String songId) { final value = _box.get(songId); if (value == null) return null; return LikeSong.fromJson(Map<String, dynamic>.from(value as Map)); } List<LikeSong> getAllLikes() { final values = _box.values.toList(); final songs = values .map((e) => LikeSong.fromJson(Map<String, dynamic>.from(e as Map))) .toList(); songs.sort((a, b) => b.likedAt.compareTo(a.likedAt)); return songs; } Future<void> clearAll() async { await _box.clear(); } }

有几个点说一下。

Hive的Box打开之后会常驻内存,不要反复打开关闭。我在App启动阶段调用一次init,后续全局复用。

_box.openBox<Map>这里我用了Map类型。Hive支持直接存取对象,但需要给对象写TypeAdapter;我的做法是序列化成Map再存,少写一个Adapter,反正字段不多,可读性也更好。

最后有一个细节:如果Box里的数据是旧版本模型,比如字段从likedAt改成了createTime,那么fromJson里的空值兜底就要兜得住。实际项目中建议再包一层try-catch,把损坏数据单独隔离而不是让它拖垮整个list。

2.3 数据写盘失败的兜底策略

写盘失败的场景在真机上真的会遇到:存储空间满了、权限异常、文件被系统清理。我的策略很简单:先改内存、再落盘,落盘失败回滚内存。

为什么不是先落盘再改内存?因为用户点击心形按钮,UI如果要在磁盘写完之后才变化,网络差或IO慢的时候会明显卡顿。正确的体感是:按钮瞬间变红/变灰,后台异步写盘,失败了再回滚。

DAO层和Controller层要配合好,这部分逻辑在下一章展开。

3. 状态层:喜欢状态全局同步

3.1 LikeController的结构设计

状态层的核心是一个全局单例的LikeController,它维护两份内存数据:

class LikeController extends ChangeNotifier { final LikedSongsDao _dao; final Set<String> _likedIds = {}; final List<LikeSong> _likedSongs = []; bool _loaded = false; LikeController(this._dao); bool get isLoaded => _loaded; List<LikeSong> get likedSongs => List.unmodifiable(_likedSongs); int get count => _likedIds.length; bool isLiked(String songId) => _likedIds.contains(songId); }

为什么要维护两份?_likedIds是一个Set,用来做“某首歌是否喜欢”的O(1)判断。在列表页构建几千行时,如果每次都去遍历List,性能就是灾难。_likedSongs则负责给UI列表提供有序数据。两份数据在增删时必须同步更新,这是最容易出bug的地方,我会封装私有方法统一操作。

加载数据的方法也很直接:

Future<void> load() async { final list = await _dao.getAllLikes(); _likedSongs ..clear() ..addAll(list); _likedIds.clear(); for (final song in list) { _likedIds.add(song.songId); } _loaded = true; notifyListeners(); }

load一般放在App启动流程里。注意load方法只在内存中重建数据,不写盘。重复调用不会引发数据重复,因为clear了。

3.2 toggleLike的完整逻辑与回滚

核心方法是toggleLike,也是所有业务入口最终都会调用的方法。

Future<void> toggleLike(SongModel song) async { final wasLiked = _likedIds.contains(song.songId); if (wasLiked) { _likedIds.remove(song.songId); _likedSongs.removeWhere((s) => s.songId == song.songId); } else { final likeSong = LikeSong( songId: song.songId, songName: song.songName, artist: song.artist, album: song.album, duration: song.duration, coverUrl: song.coverUrl, likedAt: DateTime.now().millisecondsSinceEpoch, ); _likedIds.add(song.songId); _likedSongs.insert(0, likeSong); } notifyListeners(); try { if (wasLiked) { await _dao.removeLike(song.songId); } else { await _dao.addLike(_likedSongs.firstWhere((s) => s.songId == song.songId)); } } catch (e) { // 回滚内存状态 if (wasLiked) { final originalIndex = _likedSongs.indexWhere((s) => s.songId == song.songId); final restored = _likedSongs[originalIndex]; _likedIds.add(song.songId); _likedSongs.insert(originalIndex, restored); } else { _likedIds.remove(song.songId); _likedSongs.removeWhere((s) => s.songId == song.songId); } notifyListeners(); debugPrint('toggleLike rollback for ${song.songId}: $e'); } }

注意上面这段的回滚逻辑里,我在移除_likedSongs之后又用indexWhere去查原索引,这在真实代码里是拿不到的,所以正确写法是在修改前先把likedSong存到一个局部变量。我在这里简化了,实际项目里应该这样:

LikeSong? snapshot; if (wasLiked) { final idx = _likedSongs.indexWhere((s) => s.songId == song.songId); if (idx >= 0) snapshot = _likedSongs[idx]; }

回滚逻辑是兜底的,正常情况不会触发,但写的时候一定要把快照先存下来。另外,这个方法里调用了notifyListeners()两次(成功和回滚各一次),这样UI始终与内存最终状态一致。

3.3 冷启动加载与启动优化

冷启动时,Hive初始化需要拉取文件、反序列化,用户会看到一个加载过程。我的做法是在启动页或首页initState里调用load,并给UI一个loading状态。

还有一种做法是“先渲染、后补数据”,但收藏列表这个场景不适合:用户点进“我喜欢的音乐”,如果先看到一个空页面,零点几秒后又突然出现一堆歌,视觉跳动很怪。所以收藏页宁可多等一个loading转圈,也不要让内容闪烁。

如果你的App后面还有“每日推荐”“猜你喜欢”这种功能,加载顺序可以做个优先级:先加载最核心的播放配置,再并行加载收藏列表,不要让一个IO拖垮整个启动流程。Flutter的async并发很好写,直接用Future.wait

4. UI层:收藏列表页与喜欢按钮

4.1 收藏列表页实现

收藏列表页的整体结构比较标准:AppBar显示标题和数量,body根据状态切换加载中、空列表、列表数据三种视图。

class LikedSongsPage extends StatelessWidget { const LikedSongsPage({super.key}); @override Widget build(BuildContext context) { final controller = context.watch<LikeController>(); return Scaffold( appBar: AppBar( title: Text('我喜欢的音乐'), actions: [ if (controller.count > 0) IconButton( icon: const Icon(Icons.delete_sweep_outlined), onPressed: () => _showClearConfirm(context, controller), ), ], ), body: _buildBody(controller), ); } Widget _buildBody(LikeController controller) { if (!controller.isLoaded) { return const Center(child: CircularProgressIndicator()); } if (controller.likedSongs.isEmpty) { return const _EmptyView(); } return ListView.separated( itemCount: controller.likedSongs.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { final song = controller.likedSongs[index]; return _LikedSongTile( song: song, onTap: () => _playFromLikedSongs(context, controller, index), onRemove: () => controller.toggleLike(SongModel.fromLikeSong(song)), ); }, ); } }

空状态视图不要只放一个“暂无喜欢歌曲”的文字,太干。我放了一张本地占位图、一句“点一下心形,把喜欢的歌留在这里”、再加一个“去逛逛”按钮引导去热门歌单页。这个细节对新手用户很友好。

列表项组件里,最值得说的一点是key。ListView.separated的itemBuilder里,每一行的根widget我建议显式给一个key,值用song.songId。Flutter虽然会根据索引复用元素,但当你删除中间某一项时,有key的widget树才能精确匹配和移动,避免出现状态串位。

4.2 全局喜欢按钮的状态同步

“喜欢”按钮会出现在歌曲列表页的每一行、播放页、部分歌单详情页。这些页面之间怎么保持一致?答案只有一个:不要各自维护状态,全部走LikeController。

歌曲列表行里的心形按钮,完整写法是这样:

class _LikedSongTile extends StatelessWidget { const _LikedSongTile({ required this.song, required this.onTap, required this.onRemove, }); final LikeSong song; final VoidCallback onTap; final VoidCallback onRemove; @override Widget build(BuildContext context) { return Dismissible( key: ValueKey(song.songId), direction: DismissDirection.endToStart, confirmDismiss: (_) async { final confirmed = await showDialog<bool>( context: context, builder: (ctx) => AlertDialog( title: const Text('移除这首歌?'), content: Text('《${song.songName}》将从"我喜欢的音乐"中移除。'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('取消'), ), TextButton( onPressed: () => Navigator.pop(ctx, true), child: const Text('移除'), ), ], ), ); return confirmed ?? false; }, onDismissed: (_) => onRemove(), background: Container( color: Colors.redAccent, alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 20), child: const Icon(Icons.delete_outline, color: Colors.white), ), child: ListTile( leading: CoverThumb(url: song.coverUrl), title: Text( song.songName, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( '${song.artist} · ${song.album}', maxLines: 1, overflow: TextOverflow.ellipsis, ), trailing: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onRemove, child: Icon( Icons.favorite, color: Colors.redAccent, ), ), onTap: onTap, ), ); } }

这里有一个必须注意的坑:Dismissible的onDismissed回调触发后,这个widget会从列表数据源中被移除,但Dismissible组件本身要求“被删除的item在下一次build时不再出现”。如果onDismissed调用后,数据源没有立刻同步,就会报A dismissed Dismissible widget is still part of the tree。我的做法是:onRemove直接调用controller.toggleLike,这个方法内部会先改内存再notifyListeners,正好保证下一帧该item就消失了。

播放页的心形按钮类似,只是多了一个监听:

AnimatedBuilder( animation: likeController, builder: (context, child) { final liked = likeController.isLiked(currentSong.songId); return IconButton( icon: Icon( liked ? Icons.favorite : Icons.favorite_border, color: liked ? Colors.redAccent : Colors.white70, ), onPressed: () => likeController.toggleLike(currentSong), ); }, )

注意这里的currentSong必须是播放器当前正在播放的歌曲对象。如果同一个页面因为某种原因在播放跳转后没有刷新currentSong,按钮就会显示成上一首歌的状态。我排查过一次,原因就是播放器切歌时只更新了内部状态,没有回调UI层。

4.3 点击收藏列表项播放

从收藏列表点击一首歌,目标是把整个收藏列表作为播放队列,并从点击的这首开始播。这要求播放器服务有一个playQueue(List<SongModel> queue, int index)方法。

void _playFromLikedSongs(BuildContext context, LikeController controller, int index) { final queue = controller.likedSongs .map((e) => SongModel.fromLikeSong(e)) .toList(); PlayerService.instance.playQueue(queue, index); // 可选的页面跳转 Navigator.push(context, MaterialPageRoute( builder: (_) => const PlayerPage(), )); }

这个队列不是一次性把几百首全部塞给播放器底层,而是由PlayerService持有一个List 的引用,播放器只记录当前索引。切歌时根据播放模式(列表循环、顺序播放、随机播放)计算下一首索引。这部分前面写播放器核心链路的时候已经实现过了,这次只是把数据源换成收藏列表。

有一个容易忽略的点:如果用户正在播放A歌曲,然后去收藏列表点了B歌曲,原播放列表应该整体替换。否则从B切下一首时会突然跳回A歌曲所在的旧队列,体验非常割裂。所以playQueue里必须重置播放模式的状态。

5. 细节优化与关键取舍

5.1 取消喜欢后的数据一致性

取消喜欢这个动作有几个连带场景要处理好。

正在播放的歌被取消了喜欢,播放不能中断,只改变UI上的心形状态。等这首歌唱完或用户手动切歌,播放器继续按当前队列走。

收藏列表正在被播放时,用户滑掉了一首歌。我做过一个版本:滑掉后队列也跟着移除,结果播着播着突然少了一首,索引全乱。后来改成:滑掉只影响收藏列表,当前正在播放的队列不变,除非用户重新从收藏列表进入播放。这符合大多数音乐App的行为,至少排队逻辑不会被这种边缘操作搞乱。

重新喜欢一首刚刚取消的歌,它应该回到列表的哪个位置?我选择插入到最顶部,理由是新喜欢的时间戳是最新的,按时间倒序它就该在第一行。这个行为不用额外写逻辑,insert(0, likeSong)就搞定了。

5.2 性能与体验优化

收藏列表如果到了上千首,ListView的itemBuilder会按需构建,性能没问题。瓶颈主要在封面图加载。如果每首歌都从网络加载封面,快速滑动时会有大量请求。

我的建议是给图片组件加一层内存缓存,用cached_network_image这类库,或者你自己实现一个简单的Map<String, Uint8List>缓存。在OpenHarmony上如果某些第三方图片库的缓存路径有问题,可以退化为自研的简单内存缓存,反正封面图单个文件不大,1000首占几百MB内存在真机上可以接受,但建议设个LRU上限。

另外,列表项构建时尽量避免在build里做复杂的字符串拼接和重复的fromJson操作。把SongModel都准备好再丢给列表,不要在itemBuilder里临时new对象。

5.3 为后端同步预留扩展点

热词里有一条“Flutter 做本地数据库+后端同步”,很多音乐App最后都会做收藏云备份。我在设计LikeController的时候就留了接口:DAO层封装了所有持久化操作,业务层根本不知道数据存哪。将来要做云同步,只需要写一个RemoteLikedSongsRepository,在toggleLike成功后同时推送远端即可。

同步冲突的常规做法是用updatedAt时间戳做最后写入优先(LWW),或者用增量同步记录本地变更序列。这个属于多端同步的范畴,等真做的时候再展开,但当前这种“内存 + DAO”的结构已经为后续演进留好了空间。

6. 常见问题与踩坑实录

这里我把实际开发中遇到的高频问题整理成一张速查表,都是能直接对号入座的。

问题现象根本原因解决方案
重启App后收藏全部丢失Hive未初始化成功,或盒子未打开就读写在启动流程中await初始化,确保openBox完成后再load数据
列表滑动时心形按钮状态错乱ListView复用了item,状态只存在widget内部状态统一走LikeController,key使用songId
Dismissible滑动删除报“still part of the tree”onDismissed触发后数据源未立刻同步onRemove里先调用toggleLike改内存,再notifyListeners
播放页喜欢按钮显示上一首歌的状态切歌时只更新了播放器内部currentSong,没有触发UI重建用AnimatedBuilder监听播放器currentSong变更
OpenHarmony上Hive无法写入文件path_provider未适配,目录不可写手动获取应用沙箱路径,传给Hive.init
大量收藏时启动页面卡顿在UI线程同步反序列化大量数据load方法用async异步执行,初始化完成后notifyListeners
点击列表项心形按钮,触发了整行的播放手势冲突,点击事件被父级ListTile拦截用心形按钮的外层套GestureDetector,设置HitTestBehavior.opaque,必要时在onTap中阻止事件冒泡

有一个问题特别容易在OpenHarmony真机上遇到:某些目录在开发调试期是可写的,但打包安装后变成只读。所以Hive的初始化路径不要用相对目录或者“我以为可写”的路径,一定要在运行期通过path_provider或平台通道获取真正的应用沙箱目录。

另外,Hive的Box文件有格式版本,如果你从Git历史里拉回旧代码,可能出现“Box文件版本不兼容”的异常。我的习惯是给Box名字带上版本后缀,比如likedSongsBoxV1,将来数据结构大改时直接换新Box,老数据做一次迁移脚本,而不是在同一个Box里做破坏性的字段变更。

反编译问题也提一句。Flutter的Dart代码编译成AOT之后,反编译难度比Java原生低很多,尤其是字符串常量是留在产物里的。所以如果你在收藏功能里做了云同步,不要把用户token直接拼在SQL或Box的key里。敏感信息应该走系统级的安全存储方案,至少也要做一层加密。

最后再分享一个细节:给“我喜欢的音乐”页面做统计埋点的时候,不要只埋“点击喜欢”和“取消喜欢”,还要埋“从收藏列表播放了歌曲”这个行为。这个数据能直接反映收藏功能的真实活跃度。我后期看数据时发现,用户收藏歌曲的频次远高于从收藏列表播放的频次,这说明很多用户把这里当“歌单仓库”,而不是“播放入口”。后来我在收藏页顶部加了一个“随机播放全部”按钮,数据立刻涨了一截。

做这个功能最大的体会是:先把“状态从哪来、改到哪里去”画清楚,再写UI。代码顺序永远是数据层、状态层、UI层,反过来写一定是返工的命。如果你正在做类似的功能,希望这篇能帮你少踩几个坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 7:58:11

本地部署大模型实战:Ollama、量化与API接入全指南

去年秋天&#xff0c;我决定在自己的电脑上本地部署一个大模型。原因听起来可能有点“幼稚”——我就是受够了把私人文档和聊天记录扔给云端API&#xff0c;每次发出去总感觉有人在盯着屏幕看&#xff1b;再加上那段时间各种在线AI动不动就限流、排队、还要充值&#xff0c;我才…

作者头像 李华
网站建设 2026/9/8 7:57:46

声音控制Agent:从语音到动作的完整链路与工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/8 7:55:50

双端通讯录源码拆解:权限适配与隐私合规实战指南

简介&#xff1a;2025年6月旗舰版双端通讯录源码&#xff0c;是一套基于HBuilder X打包的iOS与安卓通讯录应用项目&#xff0c;面向需要开发通讯录、相册、短信、手机号定位及已安装APP信息展示等功能的移动端开发者。代码无加密&#xff0c;后端采用ThinkPHP框架&#xff0c;前…

作者头像 李华
网站建设 2026/9/8 7:55:16

嵌入式系统5小时速成:STM32考点与Keil环境搭建实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/8 7:54:52

AI编程助手如何理解代码库?从原理到工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华