资讯动态

Flutter for OpenHarmony微动漫App历史记录模块的落地实现与原生RDB桥接

发布时间:2026/10/14 8:23:35 来源:尧图企业网站定制
如果你正在折腾 Flutter for OpenHarmony又恰好要做一个微动漫App那“历史记录”这个模块大概率会从“小需求”变成“拦路虎”。页面入口就一个看起来只是个列表加删除可真要动手才发现阅读进度什么时候存、存哪里、怎么读回来、跨页面怎么同步每一环都得重新设计。这篇文章我就把自己在微动漫App实战里实现历史记录模块的完整过程、踩过的坑和最终能跑通的方案整理出来给同样走 Flutter for OpenHarmony 方向的朋友一个可以直接参考的落地路径。先说结论这个模块在 Android/iOS 上可能就是个sqfliteChangeNotifier的事但换到 OpenHarmony 环境之后第三方插件适配参差不齐原生桥接的细节也和你熟悉的套路不一样。我最终舍弃了“优先考虑第三方插件”的惯性思维改成在 ArkTS 侧直接用系统关系型数据库能力再用 MethodChannel 桥接给 Flutter 侧调用。下面我把从数据模型设计到 UI 交互的完整实现过程拆开讲过程中会带上可直接抄的建表语句、关键代码和排错经验。1. 先想清楚再动手历史记录在微动漫App里到底要存什么1.1 业务场景拆解继续观看不是单纯查一条数据微动漫App 的历史记录表面上是“用户看过的内容列表”实际上服务的是三个核心场景。第一个是断点续读用户上一次看到《某作品》第 25 话的 63% 位置退出后再打开希望首页直接给一个“继续观看”的入口点击后能回到上次那个章节、那个位置。第二个是最近阅读回看用户想看自己三天前追过的作品从历史列表页翻出来这时候需要展示封面、作品名、章节名、阅读时间这些信息。第三个是记录管理单条删除、清空历史、甚至历史开关虽然逻辑简单但交互入口躲不掉。这三个场景叠加意味着历史记录不只是“一条数据”而是“作品维度 章节维度 进度维度”的组合。作品维度记录用户对哪部作品感兴趣章节维度记录看到第几话进度维度记录当前这一话读到什么位置。如果只存作品 ID用户点进去还得重新找章节如果只存章节 ID作品封面的展示又得额外请求接口。所以我建议把作品名、封面、章节名这些展示字段直接冗余到历史表里列表页查询一次数据库就能渲染不用回源网络请求。这个“快照”设计非常关键微动漫场景下历史列表往往要秒开每一次网络请求都会让用户产生“卡”的感觉。1.2 单机存储还是账号云同步先别急着写代码动手之前纠结最多的一个问题历史记录要不要接账号、做云同步我的建议是初始版本先做本地但表结构上预留跨端字段。原因很简单微动漫App 前期的用户高频场景是“一个设备上追番刷漫画”本地数据完全够用。就算用户换设备首次版本不承诺多端同步产品体验也不会有硬伤。反过来如果一开始就设计云同步你需要处理同步冲突、合并策略、离线写入、服务端接口设计这些复杂度足以让你在历史记录模块上多耗费两倍时间。我在表结构里留了两个字段作为后续扩展updateTime和deviceId。updateTime用来做乐观锁和增量同步deviceId用来识别数据来源。这样以后真要接账号系统不需要改表结构只需要写一个同步服务把本地数据推到远端再拉下来合并。数据量方面也不用担心一个重度用户的历史记录撑死几千条本地数据库的容量和性能都能轻松覆盖没必要为了“云同步”破坏本地方案的简洁性。1.3 我最终圈定的数据模型与字段设计我用的数据模型是下面这张history_record表。字段名类型说明idINTEGER PRIMARY KEY AUTOINCREMENT本地自增主键work_idTEXT作品唯一标识work_nameTEXT作品名快照cover_urlTEXT封面图快照chapter_idTEXT章节唯一标识chapter_titleTEXT章节名快照progressINTEGER阅读进度百分比0-100positionINTEGER滚动类阅读的偏移量可选字段last_read_timeINTEGER最后阅读时间戳秒级update_timeINTEGER数据更新时间戳秒级预留同步device_idTEXT设备标识预留多端同步建表时最重要的一步是加唯一约束work_idchapter_id联合唯一。这背后的逻辑是用户反复阅读同一章节时我们希望更新这一条记录的进度和时间而不是每次都插一条新记录。如果不加唯一约束历史列表会越跑越长全是同作品的重复项。索引方面last_read_time字段一定要建索引。因为历史记录页的核心查询是“按最后阅读时间倒序取最近 N 条”没有索引的话数据量一旦涨到几百条查询性能就会明显下滑。在微动漫场景下虽然不至于出现卡顿到不能用但这个习惯要养成。另外一个容易忽略的点progress我用整数百分比而不是具体的滚动偏移量。原因是不同尺寸的设备上同一章节内容描述中的“读到哪一行”本身不具备跨设备通用的能力而百分比可以。如果未来产品需要精确到“滚动到某个位置”再单独为阅读器维护位置表不要和历史记录混在一起。2. 存储层选型Flutter for OpenHarmony 下为什么绕开原生插件直接桥接 RDB2.1 三个备选存储方案横向对比在 Android/iOS 下实现历史记录大多数开发者第一反应是sqflite或者drift。但在 Flutter for OpenHarmony 环境下我明确不建议在一开始就把第三方插件优先级放太高建议先做一轮对比再定方案。方案优点缺点SharedPreferences / KV 缓存接入简单set 一下就能读全量读写数据一多性能差没有条件查询、排序、分页能力不适合存结构化列表第三方 SQLite 插件API 成熟开发体验好OpenHarmony 生态下的第三方 Flutter 插件适配参差不齐部分插件能跑但版本滞后出了问题排查成本高OpenHarmony 原生 RDB MethodChannel存储能力来自系统侧稳定性有保障支持 SQL、事务、索引结构清晰Dart 侧只拿到 JSON 数据需要同时写 Dart 侧和 ArkTS 侧代码但不是每个操作都复杂实际成本可控考虑了很久最后选了原生 RDB MethodChannel 的组合。原因有三第一历史记录是典型的结构化数据天然需要 SQL 的排序、去重、范围查询能力SharedPreferences 给不了第二Flutter for OpenHarmony 的插件生态还处在发展阶段与其赌第三方 SQLite 插件的适配质量不如用系统侧稳定提供的存储能力第三MethodChannel 这种桥接方式虽然多写一层代码但换来的是对数据存取全流程的掌控排错时能清楚地知道是 Dart 侧问题还是原生侧问题。我在做某微动漫项目时还试过一个折中方案用纯 Dart 写的本地数据类库把数据维护在内存里定期序列化到文件。这个方案在某些极简场景下也能工作但一旦涉及按时间排序分页、增量更新、多条件过滤手写逻辑的复杂度和 Bug 风险马上上来。所以最终没有采用。2.2 用 MethodChannel 把 Dart 侧和 ArkTS 侧缝起来MethodChannel 可以理解成两个办公室之间的传话窗口。Dart 侧说 Dart 语言ArkTS 侧说 ArkTS 语言中间需要一种双方都能读懂的“便签条”这里我统一用 JSON 字符串作为便签条。通道名称的命名要统一规则我习惯用“项目标识 模块 用途”比如com.example.microcartoon.history.store。这个字符串在 Dart 侧和 ArkTS 侧必须完全一致少一个字符都调不通。真遇到“调用没反应”第一步就是检查两边的通道名是否一字不差。Dart 侧的核心就是定义通道并调用方法class MethodChannelHistoryRepository implements HistoryRepository { static const _channel MethodChannel(com.example.microcartoon.history.store); override Futurevoid insertOrReplace(HistoryRecord record) async { await _channel.invokeMethod(upsert, record.toJson()); } override FutureListHistoryRecord queryRecent({int limit 50}) async { final result await _channel .invokeMethodListdynamic(queryRecent, {limit: limit}); if (result null) return []; return result .map((e) HistoryRecord.fromJson(e as MapString, dynamic)) .toList(); } override Futurevoid deleteByWork(String workId) async { await _channel.invokeMethod(deleteByWork, {workId: workId}); } override Futurevoid deleteAll() async { await _channel.invokeMethod(deleteAll); } }这里注意两点invokeMethod是异步的调用方该 await 就 await常见错误是写库还没完成 UI 就刷新了导致列表缺数据返回值统一走 JSONDart 侧不要指望原生侧返回自定义类型JSON 是最稳妥的跨语言格式。ArkTS 侧的工作是在应用入口注册同一个通道的处理器然后根据call.method分发到对应的数据库操作类。// 简化示意在应用初始化时注册通道 let historyChannel new MethodChannel(com.example.microcartoon.history.store); historyChannel.setMethodCallHandler((call) { let data call.arguments as Recordstring, Object; switch (call.method) { case upsert: // 在这里调用 RDB 的插入或更新逻辑 break; case queryRecent: // 在这里查询并按 last_read_time 倒序返回 break; case deleteByWork: break; case deleteAll: break; default: break; } });关键点在于 ArkTS 侧的数据库操作要放在一个独立的封装类里通道处理器只做路由分发不写具体 SQL。这样应用的其他模块也能复用这套数据库能力而不是把逻辑全部堆在入口文件的回调里。2.3 建表 SQL 与数据访问接口设计数据库表结构在前面已经给了。这里补充完整建表 SQLCREATE TABLE IF NOT EXISTS history_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, work_id TEXT NOT NULL, work_name TEXT NOT NULL, cover_url TEXT, chapter_id TEXT NOT NULL, chapter_title TEXT, progress INTEGER DEFAULT 0, position INTEGER DEFAULT 0, last_read_time INTEGER NOT NULL, update_time INTEGER NOT NULL, device_id TEXT, UNIQUE(work_id, chapter_id) ); CREATE INDEX IF NOT EXISTS idx_history_last_read_time ON history_record (last_read_time DESC);核心 SQL 语句就这么几条upsert 操作INSERT INTO history_record (work_id, work_name, cover_url, chapter_id, chapter_title, progress, position, last_read_time, update_time, device_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(work_id, chapter_id) DO UPDATE SET progress excluded.progress, position excluded.position, last_read_time excluded.last_read_time, update_time excluded.update_time, chapter_title excluded.chapter_title查询最近列表SELECT * FROM history_record ORDER BY last_read_time DESC LIMIT ?查询某个作品的最新记录SELECT * FROM history_record WHERE work_id ? ORDER BY last_read_time DESC LIMIT 1删除某个作品DELETE FROM history_record WHERE work_id ?清空全部DELETE FROM history_record查询最新记录这条 SQL 用得很多。首页“继续观看”卡片只需要某部作品的最新进度不需要把整张历史表读出来用LIMIT 1是性能最稳的方式。如果将来要做“按作品维度去重的最近阅读列表”可以把查询改为按work_id分组取每组最新一条这一步可以在原生侧用 SQL 完成也可以在 Flutter 侧拿到列表后按work_id去重看数据量决定。3. 服务层实现防抖、批量落盘与生命周期兜底3.1 仓储接口先行Dart 侧与原生侧彻底解耦我不建议把 MethodChannel 调用直接写进页面里那样会让 UI 层依赖底层传输细节以后换成别的存储方案就得改页面代码。正确的姿势是先定义一个HistoryRepository抽象接口业务层只依赖接口。abstract class HistoryRepository { Futurevoid insertOrReplace(HistoryRecord record); FutureListHistoryRecord queryRecent({int limit 50}); FutureHistoryRecord? queryLastByWork(String workId); Futurevoid deleteByWork(String workId); Futurevoid deleteAll(); }接口的好处是当你未来从 MethodChannel 切换到云端同步或者改成本地文件存储时只需要新增一个实现类业务层代码完全不用动。我在项目里就是先用接口顶着 MethodChannel 实现后面接入账号系统时自然会替换成“网络 本地”的双写实现。实际写的时候不需要引入依赖注入框架构造函数里直接传实现类就够了。示例class HistoryService { final HistoryRepository _repository; HistoryService(this._repository); }这样代码路径非常清晰页面 → 服务 → 仓库接口 → MethodChannel 实现 → ArkTS 原生 RDB。每一层的职责单一出问题能快速定位。3.2 进度上报的正确姿势500ms 防抖加退出强制 flush用户阅读时进度上报的触发频率是非常高的。以漫画阅读器为例用户左右翻页每隔几秒就会产生一次新进度如果以滚动方式阅读进度变化更是几十毫秒一次。如果每次变化都直接写数据库不仅性能差还容易造成同一章节的多条并发写冲突。我的做法是加一层 500ms 防抖。核心逻辑很简单每次收到新进度就先缓存到内存_pending重置一个 500ms 的定时器。定时器触发时才真正调用仓库写入。如果用户在 500ms 内又产生了新进度那定时器重新计时只保留最新的一条进度。class HistoryService { final HistoryRepository _repository; Timer? _debounce; HistoryRecord? _pending; void report(HistoryRecord record) { _pending record; _debounce?.cancel(); _debounce Timer(const Duration(milliseconds: 500), flush); } Futurevoid flush() async { final record _pending; _pending null; if (record null) return; await _repository.insertOrReplace(record); } }选择 500ms 而不是更短的 100ms是因为阅读器场景里连续翻页的间隔通常在 200ms 到 400ms 之间。太短的防抖窗口没法很好合并连续翻页太长又会让用户快速退出后进度丢失。实测下来 500ms 能在性能和实时性之间取得一个平衡点。这里有一个必须注意的细节_pending只保存最新的进度但如果用户从第 25 话直接跳到第 30 话这两条记录分别属于不同章节都应该写入数据库。我的report方法里针对这种情况做了分支如果新记录和你正在等待写入的记录是同一个workId chapterId那直接覆盖否则先把上一条_pending强制 flush 掉再缓存新的。这样可以避免跨章节跳转时漏记录。3.3 生命周期兜底别让最后一条记录丢在应用被杀的路上防抖解决的是高频写入问题但只做防抖不够。用户可能在点击翻页后立刻按下 Home 键退出应用进入后台后不久被系统清理定时器还没触发写库就被杀掉了。最后一条进度就丢了。解决方案是监听应用生命周期在应用进入后台或页面销毁时强制把_pending落盘。Flutter 里可以用AppLifecycleListener或WidgetsBindingObserver我更推荐AppLifecycleListener它可以更细粒度地监听paused、hidden、inactive状态。class HistoryService { late final AppLifecycleListener _lifecycleListener; void _initLifecycleListener() { _lifecycleListener AppLifecycleListener( onStateChange: (state) { if (state AppLifecycleState.paused || state AppLifecycleState.hidden || state AppLifecycleState.inactive) { flush(); } }, ); } }有人会问那定时器可能还没到 500ms强制 flush 会不会导致写入太频繁其实不会因为_pending本来的语义就是“最终要写入的最新值”生命周期兜底只是在时机上提前了并不会造成重复写。还有一个容易被忽视的场景用户在阅读器页面点左上角返回时路由 Pop 之前也应该主动调用一次flush()。因为路由销毁后页面里的计时器同样会被释放如果不手动 flush用户明明读了这一话历史记录却停留在上一话。这个问题我在初版上线后就遇到用户反馈“看完退出再进来没记住”排查半天才发现是 Pop 没触发兜底。4. 历史记录页 UI 实战列表、空态与删除交互4.1 继续观看卡片与历史列表的分工微动漫App 的历史记录并不只有独立二级页一个入口首页顶部通常还会有一个“最近阅读”卡片。这两者的数据来源是同一个仓库接口但呈现逻辑不一样。首页卡片只需要显示最近一次阅读的作品信息我采用queryLastByWork查询最新一条记录并直接渲染封面、作品名、章节名、进度百分比。点击后带着workId、chapterId、progress跳转到阅读器阅读器内部再根据进度定位。独立历史页则展示完整的最近列表默认拉取最近 50 条。这个数量是我故意设置的50 条足够覆盖大多数用户的阅读轨迹同时列表滚动时的性能压力不会太大如果未来需要更多翻页加载就是但初版用固定限制更省事。历史页的事件驱动模型我用ChangeNotifierValueListenableBuilder。查询完成后通知监听者列表页重建执行删除或清空后再次通知。不引入重量级状态管理框架原因很简单历史记录模块在 App 中的状态边界非常清晰它不需要跨页面共享复杂状态最朴素的方案反而是最好维护的。4.2 按时间分组的列表与进度条展示历史列表的展示方式我采用了“时间分组 横向卡片”的组合。时间分组就是把记录按“今天”“昨天”“更早”分桶。这个功能在 SQL 侧不需要做特殊聚合只需一次性把所有记录读出来然后在 Flutter 侧用lastReadTime换算成日期再按日期分组。MapString, ListHistoryRecord _groupByDate(ListHistoryRecord records) { final result String, ListHistoryRecord{}; final now DateTime.now(); for (final record in records) { final date DateTime.fromMillisecondsSinceEpoch(record.lastReadTime * 1000); final key _dateKey(date, now); result.putIfAbsent(key, () []).add(record); } return result; }_dateKey的逻辑很简单日期相同就是“今天”日期相差 1 天就是“昨天”否则是“更早”。不要试图在 SQL 里做这个分组因为时区处理非常麻烦Flutter 侧有DateTime的本地时区支持写起来更顺手。列表项 UI 分成三个区域左边封面缩略图、中间标题和进度信息、右边最后阅读时间。中间区域用三行文本展示作品名、章节名、进度百分比。进度条我会做一个很细的线型进度条放在时间信息的下面既不会抢占视觉重心又能让用户一眼看出“这部作品看到了哪里”。封面图加载建议使用带缓存机制的图片库别直接用Image.network。原因很实际历史列表里大量相同封面图反复滚动重建如果没有内存缓存缩略图每次都要走网络列表会明显卡顿。我在项目里封装了一个带内存和磁盘缓存的图片组件缓存键直接用coverUrl。4.3 三种删除方式与清空历史删除交互有单条左滑删除、编辑模式批量删除、清空历史三种分别对应不同的用户意图。单条左滑删除用Dismissible实现滑动方向是横向。注意Dismissible的confirmDismiss里执行删除后要同步调用仓库接口并等数据库操作完成后再从 UI 列表移除否则会出现“界面消失了实际数据还在库里拖后腿”的情况。编辑模式批量删除是历史页右上角的“管理”按钮进入。进入编辑态后列表项的左侧出现勾选框底部弹出一个工具栏显示当前选中数量和“删除”按钮。这种交互在移动端很常见对用户来说并不陌生。实现上我维护一个选中集合SetString _selectedWorkIds点击删除按钮后把所有选中的workId批量传给仓库。清空历史必须有二次确认。我用一个AlertDialog弹出确认框文案明确写“清空后无法恢复”按钮默认红色。确认后调用deleteAll()同时清空内存中的缓存列表再通知 UI 刷新。删除相关有个小细节清空历史时首页“继续观看”卡片也应该同步消失。所以我会在deleteAll()之后额外触发一次首页数据的刷新通知确保整个 App 的状态一致。这种跨页面联动问题是最容易漏的我在初版实现里就漏过一次导致清空历史退回首页发现卡片还停在那里。5. 常见问题与排查技巧实录我把踩过的坑都列出来5.1 记录不生效先查通道名再查异步回调历史记录最常见的问题就是“明明调用了写入但列表页查不到”。我遇到过的原因主要有三个。第一MethodChannel 的通道名在 Dart 侧和 ArkTS 侧不一致。表面上看代码都执行了也没有报错但原生侧根本没收到调用。排查方法是把通道名直接打印出来或写死在常量文件里两端共用一份避免手工复制出错。第二invokeMethod调用没有 await导致写入操作还没完成页面就执行了查询。这种问题在快速操作时最容易出现表现为“第一次查不到刷新一下又有了”。解决方案是数据操作一律 await并且不要用unawaited去忽略写库行为。第三ArkTS 侧在处理upsert时抛了异常但没回调错误到 Dart 侧。MethodChannel 调用如果原生侧没有正确把异常抛出来Dart 侧会一直等表现就是“卡住但不报错”。建议在原生侧每个 case 里都包一层 try-catch并且把异常信息通过result.error返回。我在实际开发中会要求自己把整套桥接调用链路全打上日志从“Dart 类进方法”到“原生侧收方法”到“SQL 执行完”三级日志几乎能一次性定位 80% 的问题。5.2 进度错乱与并发写入事务和 upsert 是底线进度错乱表现为“明明读到了 80%历史记录里却显示 20%”。原因基本是并发写入。场景是这样用户连续翻页第一次report写入 30%第二次report写入 80%但由于两次写入执行顺序错乱后到的反而先写库最终库里是 30%。我在服务层用防抖 覆盖_pending规避了大部分这种问题。但如果在生命周期flush()和定时器flush()同时触发还是存在并发调用风险。解决办法是在写入端使用事务并且依赖建表时的唯一约束做 upsert。在 ArkTS 侧我可以把INSERT ... ON CONFLICT DO UPDATE包在事务里执行事务能保证写库操作串行化。同时服务层给flush()加一个简单的执行锁同一时刻只允许一个写入进行。排错时怎么判断是不是并发问题写一个模拟脚本连续调用 100 次report每次传不同的进度值最后查询数据库。如果最终进度不是最后一次的值说明写入端有并发问题。5.3 时间显示错乱别用两个时间体系格式化历史记录的“最后阅读时间”在列表页显示成“3 分钟前”“昨天 14:30”我在项目里就踩过时间单位不一致的坑。Flutter 的DateTime.now()默认是毫秒级时间戳而我在数据库里定义的last_read_time是秒级时间戳。写入时如果直接存millisecondsSinceEpoch没问题但在 Flutter 侧读取后没有乘以 1000 就转DateTime显示出来的时间会比真实时间早很多几乎像是 1970 年。口诀就是写入统一转秒读取统一转毫秒。我封装了一个工具int toSecondTimestamp(DateTime time) time.millisecondsSinceEpoch ~/ 1000; DateTime fromSecondTimestamp(int seconds) DateTime.fromMillisecondsSinceEpoch(seconds * 1000);整个项目的历史记录模块只允许通过这两个函数做时间戳转换其他地方不许直接手动算。这样统一下来时间相关问题基本绝迹。5.4 列表不刷新与页面卡顿列表不刷新通常有两类表现。第一类是写库成功但页面没变化原因是状态通知时机不对。我见过有人把数据写入放在report()开头还没等写库完成就notifyListeners()用户看到的列表数据其实是旧的。正确做法是先完成写库再通知监听者最后通过ValueListenableBuilder重新拉取列表。第二类是查询列表后手动 setState但数据源是同一个对象引用UI 层认为没有变化就不重建。排查时可以打印记录数量或最后一条的lastReadTime确认数据确实更新后再看 UI 层是否收到了新对象。页面卡顿则要分两种场景。列表滚动时封面图反复加载这个问题在 4.2 提到过用带缓存的图片组件解决。另一种是列表构建时把时间格式化放在build方法里反复执行每次滚动都会重新格式化。我对时间文案也加了一层缓存每个时间戳只格式化一次结果存放在 map 里数据更新时才清掉缓存。这一层优化在数据量大时尤其有效。5.5 适配 OpenHarmony 时的其他隐蔽问题最后说几个只在 Flutter for OpenHarmony 适配中会遇到的坑其他平台基本不会触发。数据库文件路径不能用path_provider的getApplicationDocumentsDirectory直接拿。OpenHarmony 的沙箱路径体系和 Android 不完全一致同一个接口在适配栈下拿到的路径可能不可写或者位置不对。我最终的做法是在 ArkTS 侧通过系统接口获取应用沙箱目录再拼接数据库文件名然后整体通过配置传给 Dart 侧不走path_provider。应用升级后的数据库迁移也是一个大坑。初版可能没建update_time字段第二次迭代加字段时如果没写迁移逻辑老用户升级后会直接崩。Flutter 层代码看不到任何异常但原生侧一执行INSERT就报no such column。我在 RDB 封装类里维护了一个DB_VERSION常量每次建表或改表都先查版本号再做对应迁移。阅读器跳转恢复进度时如果之前存的是滚动偏移量要注意不同设备分辨率不同偏移量初始还原的位置可能偏差很大。所以我保存position的同时还会保存一个progress百分比阅读器优先用百分比定位百分比缺失时才用偏移量。这个双保险逻辑在隐私模式下清掉数据后也能稳定工作。另外MethodChannel 在 Flutter 侧接收原生返回的Listdynamic时内部元素其实都是MapString, dynamic但 Dart 的类型推断可能给它标成ListObject甚至ListMapdynamic, dynamic。我在转模型前先做一次MapString, dynamic.from(item)操作避免强转崩溃。这个小问题浪费过我不少时间提前写上能省很多事。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑