ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter跨平台开发实战:命题逻辑与鸿蒙响应式引擎

Flutter跨平台开发实战:命题逻辑与鸿蒙响应式引擎 第一次看到“Flutter跨平台开发实战: 鸿蒙与离散数学系列命题逻辑与响应式引擎”这个标题很多人第一反应是这怕不是哪个缝合怪课程。实际上做 Flutter 开发久了就会明白响应式引擎并不是什么玄学它干的事和离散数学里的命题逻辑几乎一一对应状态就是命题UI 就是表达式求值的结果状态一变界面自动重算。而鸿蒙只是这套逻辑要落地的新平台。这篇文章想做的事有三件用命题逻辑重新理解 Flutter 的响应式机制把 Flutter 在鸿蒙上的接入思路和常见坑讲清楚最后给出一个用命题思想写的轻量状态管理 demo。整个过程不堆学术名词只讲你在写代码时真正用得上、踩得到的东西。1. 标题拆开看一次跨端开发与离散数学的“缝合”1.1 四个关键词其实可以连成一条线Flutter 是跨端 UI 框架一套代码跑 Android、iOS、Web、桌面现在还要覆盖鸿蒙。离散数学和命题逻辑是描述“真假关系”的数学工具过去常见于编译原理、数据库和算法设计在 UI 开发里反而容易被忽略。响应式引擎则是一个很工程化的词指的是“状态变了界面自动更新”的那套底层机制。把它们串起来看Flutter 里写页面本质上是在写Widget f(state)。状态就是一组命题Widget 树就是一个布尔表达式求值后的结果。状态变化时框架重新执行一次求值、更新界面这就是响应式引擎在做的事。鸿蒙在这条线里的角色比较特殊它不是新的开发范式而是 Flutter 这套机制需要适配的一个新运行环境。理解了前面的命题逻辑换平台只是换 API 名称核心思维方式不用换。1.2 你会在什么时候觉得这些知识有用我在实际咨询里见到过几种典型场景正好对应这篇文章的适用人群。第一种Flutter 新手。网上教程会教 setState、Provider、Bloc但很少解释为什么状态变了 UI 就会变。于是遇到复杂状态就靠“试”一个按钮显不显示要写三层 if最后自己都绕晕。这种人缺的不是更多库而是一套建模工具命题逻辑就是最合适的建模工具。第二种从其他框架转过来的前端或客户端开发。习惯命令式开发比如以前是textView.text hello、button.setEnabled(false)到了 Flutter 发现没有这种 API所有变化都是“改数据UI 自己跟着变”。思维转换过程中将状态视为命题能帮助更快适应。第三种正在做鸿蒙适配或跨端迁移的人。鸿蒙有自己的 ArkUI 声明式开发也是“状态变量变了组件自动更新”但具体写法跟 Flutter 不一样。如果你先理解了命题逻辑再看 ArkUI 的State、Link会发现它们只是不同形式的“命题容器”。1.3 这篇文章不解决什么先说清楚边界避免期望偏差。我不会鼓吹“数学万能”。响应式引擎不是从命题逻辑推导出来的它是由渲染管线、脏标记、事件循环这些工程机制实现的。命题逻辑在这里是一种特别好用的思维方式用来组织状态、避免逻辑混乱、设计可测试的状态模型实用性远高于玄学感。同时文章也不会深入鸿蒙源码或者 Flutter Engine 底层。到“能帮你把项目跑起来、知道报错是什么意思、能自己写出清晰的状态逻辑”这个程度就停。2. 命题逻辑基础把 UI 状态当成一组真假命题2.1 原子命题和状态变量的一一对应离散数学里的命题是一个能判断真假的陈述句。“用户已登录”是命题“正在加载”是命题“协议已勾选”也是命题。“今天天气怎么样”不是命题因为它是问句没法判断真假。UI 里的状态变量本质上就是一堆命题。isLoggedIn为 true对应命题“用户已登录”为真isLoading为 false对应命题“正在加载”为假。哪怕状态不是 bool 类型比如一个enum Status { idle, loading, success, error }你也可以把它拆成若干个派生命题isLoading status Status.loadingisError status Status.error。这种拆法有两个好处一是让每个 UI 条件的来源变得明确二是能直接用命题逻辑规则去组合和化简而不是靠眼睛看。2.2 联结词与、或、非就是代码里的逻辑运算命题逻辑有三套基本操作否定、合取、析取。对应到 Dart 代码就是!、、||。它们本身很简单但很多人没有意识到它们就是 UI 条件渲染的唯一底层工具。数学符号名称Dart 语法UI 场景¬A否定!isLoading非加载状态才显示内容A ∧ B合取isLoggedIn hasProfile登录且有头像才显示头像组件A ∨ B析取isLoggedIn || isGuest登录或游客模式都能看到购物车A → B蕴含if (A) B条件成立才执行某逻辑A ↔ B等价a b两个状态必须保持一致我见过很多质量很差的页面问题不在业务复杂而是开发者把、||、!全部展开了还嵌套四五层 if。比如“已登录且不是被封禁用户或者体验白名单用户”写成表达式就是(isLoggedIn !isBanned) || isWhitelisted。用命题逻辑看这只是一个很普通的布尔表达式完全可以提取成一个命名的 bool 变量让代码可读性翻倍。2.3 真值表写代码之前先穷举界面分支推导一条 UI 规则时我最推荐的方法是先列真值表。假设要显示“管理入口”按钮条件是“已登录且是管理员”即P isLoggedIn isAdmin。真值表长这样isLoggedInisAdmin显示管理入口truetruetruetruefalsefalsefalsetruefalsefalsefalsefalse这看起来确实简单但如果状态变量有四个、五个组合数就是 16 或 32 种。很多 bug 恰恰是没穷举就写了代码结果遗漏了“登录中且请求失败”这种组合界面出现了又加载又报错的奇怪状态。我自己的习惯是当一个 UI 条件涉及两个以上状态变量时先在草稿纸上画真值表把不可能出现的组合划掉再看剩下的组合哪些会共用同一个 UI 分支。你会发现大多数页面真正需要处理的组合远小于 2 的 n 次方。这就是命题逻辑在 UI 开发里最实用的价值它逼你把分支想清楚。2.4 蕴含、等价在代码里的投影很多教科书讲完“与或非”就结束了但 UI 开发里还经常用到蕴含和等价。蕴含A → B在代码里的投影是if (A) { ... }但它不等同于 UI 条件渲染。if (A) C else D其实更像一个“布尔选择器”它根据命题 A 的真假从两个候选中选一个而不是逻辑蕴含。理解这个差异能避免你写出反直觉的复杂表达式。等价关系则更像状态同步。比如我们经常遇到的“子组件里改了某个值父组件也要同步更新”本质上就是两个命题必须保持等价比如localValue remoteValue。在 Flutter 里你会用ValueNotifier或InheritedWidget来维护这种等价关系。在鸿蒙 ArkUI 里对应机制是Link装饰器它让两个组件共享同一个状态源天然维持等价。另外提醒一下Dart 的bool?是三值逻辑不是标准命题逻辑。如果状态可能为 null真值表就不再是二值处理起来格外小心。写 UI 条件时我建议把bool?在入口处就归一化为非空bool避免后面每条表达式都要处理 null。3. Flutter 响应式引擎一次“真值变化”怎样变成新画面3.1 声明式 UI 与命令式的本质区别传统命令式开发里数据和界面是分离的数据变了开发者需要手动找到控件调用方法去改。比如 Android 里textView.setText(...)或者 JavaScript 里document.getElementById(x).innerText ...。这也意味着界面上每个控件的可见性、文案、交互状态都需要一套对应的“更新代码”。状态多一点代码量会线性膨胀。Flutter 的声明式写法完全反过来。你写的build方法只是描述“当前状态下界面应该长什么样”。状态发生变化后开发者唯一要做的是通知框架“某个命题变了”框架重新执行build得到一棵新的 Widget 配置然后自动更新渲染层。我把这个过程类比成 Excel 表格单元格里的公式是写死的你改输入单元格公式会重新计算结果。Flutter 的build方法就是一个公式State 就是输入单元格。这里没有魔法只是一套精心设计的“重新计算 按需重绘”的机制在运行。3.2 从 setState 到渲染管线一次重建的完整路径响应式引擎的核心是状态变化后怎样触发界面重绘。很多初学者以为setState就是“重建整个页面”这个理解不够精确。更接近真相的流程是调用setState后框架会给当前 State 对应的 Element 打上脏标记。在下一帧开始时框架从根节点开始遍历有脏标记的 Element。对脏 Element 调用build生成新的 Widget 配置。把新 Widget 和旧 Element 上持有的老 Widget 做对比更新差异部分。差异更新传到底层 RenderObject最终触发绘制。这里最关键的概念是 Flutter 的三棵树Widget 配置树、Element 树、RenderObject 树。Widget 就像“图纸”每次重建都会生成一套新图纸但只有 Element 负责把图纸和实际渲染对象关联起来。Element 能复用就不重建能局部更新就不整树更新。这才是 Flutter 性能好的原因也是为什么“setState 粒度过大会卡顿”但“效率没有想象中低”。你不需要记住每条渲染细节但需要知道一点Flutter 的响应式不是“无脑重画”而是“标记、求值、更新”这和命题逻辑里的“状态集合变化后重新计算依赖表达式”是同一个思路。3.3 状态管理工具的本质命题变化的通知通道理解了“状态变UI 重建”之后再回头看 Flutter 生态里一堆状态管理库就不会被五花八门的名称搞晕。它们的本质都是两件事存放命题状态以及为命题变化提供通知通道。setState是最朴素的通知通道适合局部 State。ValueNotifier和ChangeNotifier把一个可监听的值包装起来value 变了就通知监听者。Stream更彻底它把一连串命题变化变成时间序列UI 通过StreamBuilder订阅这个序列。Provider、Riverpod、Bloc这些库则是在“存放状态 通知变化 依赖注入”三个维度上做了不同的封装。组件通信本质上也是命题在组件之间流动。父子组件之间通常把“子组件需要知道的命题”作为参数传进去或者通过回调把“子组件产生的事件”抛给父组件跨层组件之间用InheritedWidget或 Provider 让多个组件共享同一个命题源。你会发现命题流动的方向越清晰代码越好维护。3.4 Future.then 与微任务真值变化什么时候生效网上有个热门问题“Flutter Future 的 then 回调是放入微任务队列吗”答案是是的。Dart 的事件循环会区分微任务队列和事件队列Future.then注册的回调默认进入微任务队列当前同步代码执行完后立即执行。这件事和响应式引擎的关系在于状态命题的真值不会“瞬间翻转”。当你在事件处理函数里发起一个异步请求然后写.then((data) setState(...))状态更新并不会在当前这一行同步发生而是等当前同步任务跑完微任务队列开始执行后才统一触发。这也意味着如果你在一个事件处理里连续修改多个状态Flutter 不会为每一次修改都重绘一次它会把同一帧内的多次标记合并只重绘一次。实际开发里这个特性影响不大但排查状态时序问题时很关键。比如你在build方法里发起异步请求、再去监听 Future就可能因为时序问题导致重复请求。我的建议是网络请求和业务逻辑尽量放在事件处理函数或 BLoC 里不要在build里触发避免界面的“真值求值”和副作用混在一起。4. 鸿蒙平台上的 Flutter 跨平台实战环境、集成与避坑4.1 鸿蒙上跑 Flutter 的两条路线鸿蒙这个平台接入 Flutter 的路径目前并不是“官方 Flutter 下载页面直接支持”这么简单。大体上有两条常见路线。第一条是开源鸿蒙OpenHarmony社区维护的 Flutter 适配分支。OpenHarmony SIG 有一套基于 Flutter 的 fork代码仓库里会同时有flutter_flutter、flutter_engine、flutter_packages几个仓库需要按对应文档拉取并使用。这套方案的目标是让 Flutter 应用跑在 OpenHarmony 系统上适合做 IoT、平板、带屏设备这类场景。第二条是面向 HarmonyOS 应用的集成方式。你可以把 Flutter 模块以 AAR/HAR 等形式集成进鸿蒙工程或者用 DevEco Studio 打开 Flutter 生成的鸿蒙工程文件构建成可安装的 HAP 包。不同分支的版本号、命令和目录结构差异比较大我建议动手之前一定先看对应仓库 README不要自己凭印象操作。另外OpenHarmony 也有 x86 架构的 PC 系统镜像可以在虚拟机上跑这让没有鸿蒙真机的开发者也能先验证 Flutter 应用的跨端表现。总之路数是通的只是需要一些额外配置。4.2 最小实践步骤从零把 Flutter 工程跑到鸿蒙上我这里给一个通用流程具体命令需要以你使用的适配分支文档为准。第一步安装 DevEco Studio并配置好 HarmonyOS SDK。这个是鸿蒙应用开发的常规 IDE后面导入 Flutter 生成的鸿蒙工程会用到。第二步准备适配版 Flutter SDK。克隆或下载你选定的 Flutter fork配置环境变量FLUTTER_HOME或PATH然后运行flutter doctor检查环境。这里要注意一个机器上如果同时有官方 Flutter SDK 和适配版 Flutter SDK不要搞混了我建议把适配版单独放在一个目录用 shell 脚本切换。第三步创建一个标准 Flutter 工程。可以先flutter create my_app在正常 Android/iOS 环境下把业务页面开发完。这是因为 UI 逻辑和状态管理可以完全复用鸿蒙适配工作主要卡在工程集成环节而不是业务代码。第四步为工程生成或添加鸿蒙平台目录。有的 fork 提供脚本执行后会在工程里生成ohos或类似目录有的方案需要你手动创建一个鸿蒙 module再把 Flutter 编译产物引入进去。这一步不同分支差异最大务必先看文档。第五步用 DevEco Studio 打开鸿蒙工程目录配置签名编译构建 HAP。如果能跑在模拟器或真机上整个适配流程就算走通了。4.3 高发报错与排查Gradle、AAR、PlatformView、模拟器我在实际集成和帮人排查时常见问题集中在下面几个做成一个速查表现象大概率原因处理建议报错You are applying Flutters main Gradle plugin imperatively using the apply methodGradle 插件版本较新apply方式不再推荐把 Android 工程的插件声明改为plugins { id ... version ... }方式E/flutter (31173): [error:flutter/runtime/dart_vm_initializer.cc(41)]Dart 侧异步异常没有被捕获运行时统一错误出口打出来的信息用runZonedGuarded包裹runApp统一捕获并打印堆栈集成 AAR 后页面白屏FlutterEngine 和 DartExecutor 没有被正确初始化检查是否创建了 FlutterEngine并且通过 cached engine 方式关联PlatformView 加载不出内容混合栈渲染模式不兼容尝试切换 Hybrid Composition 或 Texture 模式x86 模拟器上安装 APK 失败OpenHarmony 应用是 ARM 产物在 x86 模拟器上无法直接跑找对应 x86_64 镜像或换真机验证新建 Flutter 项目跑不起来Android SDK、JDK、Gradle 版本不匹配或依赖下载失败逐个检查flutter doctor确认镜像和网络再升级依赖有一个很典型的错误解法看到 Gradle 报错就删缓存、重新 sync治标不治本。上面那个 imperative apply 的报错本质是 AGP 8 之后要求插件用plugins DSL声明你直接把android/settings.gradle里 module 的引用方式改掉问题就消失了不需要动不动清缓存。4.4 Flutter 和 ArkUI 的声明式异同如果你已经会 Flutter学鸿蒙 ArkUI 会非常快因为底层思维模型几乎一样。下面这张表可以帮你快速完成映射维度FlutterArkUI页面描述Widget build 方法自定义组件 build 方法本地状态State 类 setState组件内 State 变量父传子数据构造参数传入Prop 装饰器父子双向同步外部传 ValueNotifier 回调Link 装饰器跨页面共享Provider / Riverpod / BlocAppStorage / Provide Consume重建粒度Element diffVNode diff这里的对应关系本质上都是“命题如何存放、如何通知、如何同步”。ArkUI 的State就是一个能够触发组件重新求值的命题变量Flutter 的ValueNotifier同样也是。区别只是写法不同一个是编译期装饰器一个是普通对象。理解了命题逻辑后你不需要靠背两边 API 来迁移你只需要问一句话这个命题放在哪一层、由谁来监听、变化时谁该重新求值5. 用命题逻辑封装一个轻量响应式状态库5.1 设计目标与取舍第 2 章讲了理论第 3 章讲了 Flutter 原理第 4 章讲了鸿蒙落地。这一章直接动手写一个小型响应式库用来验证“命题即状态”这个想法。我希望这个小库能做到把 UI 里的某个可见条件声明成一个带名字的命题这个命题依赖若干个状态变量命题依赖的任何变量变化UI 自动重新求值并重建。我不打算做成生产级框架所以会接受两个简化依赖列表手动声明而不是自动收集重建粒度是整块区域而不是精细到某个子 Widget。这两个简化能大幅降低代码量同时保留核心思路。5.2 核心实现Rx、Proposition 与 PropBuilder下面是完整代码基于 Flutter 自带的ValueNotifier和StatefulWidget实现。import package:flutter/material.dart; /// 一个可监听的状态变量本质就是一个命题的“容器” class RxT extends ValueNotifierT { Rx(T value) : super(value); } /// 命题由若干状态变量计算出的布尔值 class Proposition { Proposition(this.name, this.evaluate, this.dependencies); final String name; /// 计算当前命题是否为真 final bool Function() evaluate; /// 这个命题依赖哪些状态变量 final ListValueNotifier dependencies; bool get value evaluate(); } /// 响应式组件命题依赖的状态变化时自动重建 class PropBuilder extends StatefulWidget { const PropBuilder({ super.key, required this.proposition, required this.builder, }); final Proposition proposition; final Widget Function(BuildContext context, bool value) builder; override StatePropBuilder createState() _PropBuilderState(); } class _PropBuilderState extends StatePropBuilder { override void initState() { super.initState(); for (final dep in widget.proposition.dependencies) { dep.addListener(_onChanged); } } override void didUpdateWidget(PropBuilder oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.proposition ! widget.proposition) { for (final dep in oldWidget.proposition.dependencies) { dep.removeListener(_onChanged); } for (final dep in widget.proposition.dependencies) { dep.addListener(_onChanged); } } } override void dispose() { for (final dep in widget.proposition.dependencies) { dep.removeListener(_onChanged); } super.dispose(); } void _onChanged() setState(() {}); override Widget build(BuildContext context) { return widget.builder(context, widget.proposition.value); } }代码不长但核心思想都在里面Rx持有状态并通知变化Proposition把一组状态聚合成一个布尔表达式PropBuilder监听所有依赖、变化时触发局部重建。5.3 如何用它组织一个真实页面的状态假设一个登录页面按钮只有满足“已登录、未加载中、已同意协议”三个条件时才能点击。用这个小库来描述final loggedIn Rxbool(false); final loading Rxbool(false); final agreed Rxbool(false); final canLogin Proposition( 可以点击登录, () loggedIn.value !loading.value agreed.value, [loggedIn, loading, agreed], ); // 页面里这样用 PropBuilder( proposition: canLogin, builder: (context, enable) { return ElevatedButton( onPressed: enable ? _login : null, child: const Text(登录), ); }, );这段代码的直观好处是canLogin是一个有名字的命题你在代码里看到它就知道按钮的可用条件是什么真值表里列出的四种组合可以直接转化成单元测试断言。如果后续业务加了“手机号已填写”这个条件只需要改Proposition的 evaluate 函数和依赖列表UI 不用动。还有一个好处逻辑可以复用。比如同一个页面多处地方都需要“可以登录”这个条件判断你只要把同一个Proposition对象传下去用PropBuilder监听即可。这比在每个控件里重复写loggedIn.value !loading.value agreed.value要干净得多。5.4 测试真值表把离散数学变成自动化用例响应式状态最难测的地方是逻辑分散在 UI 里。把命题单独提取出来后测试变得非常直接枚举状态组合即可void testCanLogin() { loggedIn.value false; loading.value false; agreed.value false; expect(canLogin.value, false); loggedIn.value true; loading.value false; agreed.value true; expect(canLogin.value, true); loggedIn.value true; loading.value true; agreed.value true; expect(canLogin.value, false); loggedIn.value false; loading.value false; agreed.value true; expect(canLogin.value, false); }看到没这就是把真值表原样翻译成了代码。假设你有四个状态变量那就列一张 16 行的真值表把每条规则的期望值写出来再用一个循环跑完所有组合。这个测试的维护成本极低但能挡住大量“改了一个状态界面另一处逻辑崩了”的回归问题。如果以后要做更严谨的库可以升级依赖自动收集、全局响应式上下文、批量更新 batch原理不变复杂度可控。但作为入门示例PropBuilder已经能让你体会到“命题驱动 UI”的节奏感。6. 常见问题与排查技巧实录6.1 状态改了 UI 没反应大半是“引用原地修改”Flutter 开发里频率最高的问题就是状态值看起来变了但界面没刷新。我见过最多的情况是有人把一个对象直接塞进ValueNotifier然后改对象内部字段比如user.value.name 张三。这个操作不会触发通知因为ValueNotifier只通过判定新旧对象引用是否相等来决定要不要 notify。解决办法很简单修改状态时生成新实例。比如user.value user.value.copyWith(name: 张三)。这种不可变风格跟离散数学里的“命题求值”也很契合每次状态变化都是一次新的绑定不会出现同一个对象被多处引用、互相污染的问题。6.2 setState 粒度过大导致性能下降setState的作用范围是整个 State 的build方法。如果你把一个整页大 State 的setState用在很小的局部更新上页面里无关的部分也会重新执行 build。虽然 Element 会做 diff但复杂页面还是会有可感知的损耗。更合理的做法是把需要独立刷新的区域拆成独立 StatefulWidget或者用ValueListenableBuilder、PropBuilder这类局部监听组件让每次状态变化只触发真正依赖它的那块 UI 重建。这个思路和前面命题依赖的思想完全一致每个命题只通知它的依赖者而不是通知全世界。6.3 Flutter 新建项目跑不起来的常规排查顺序很多新手在“新建项目—跑不起来”这一步就卡住了。我建议按这个顺序排查不要跳步运行flutter doctor -v看哪个组件打了叉优先处理 Android SDK、JDK 版本不匹配的问题。看 Gradle 依赖是否能正常下载国内的网络环境可能需要配置镜像仓库。检查模拟器的 CPU 架构x86 模拟器上跑 ARM-only 的应用会直接失败。如果卡在Running Gradle task assembleDebug大概率是版本号问题把 Gradle 和 Kotlin 版本调成项目模板推荐的版本。这类问题通常不是代码问题而是环境问题。经历过一次之后你会对整套构建流程的依赖关系有更具体的感知之后再遇到鸿蒙环境的集成问题排查思路是通用的。6.4 异步异常兜底不要被 dart_vm_initializer 吓到热词里那条E/flutter (31173): [error:flutter/runtime/dart_vm_initializer.cc(41)]经常被人误以为是 Flutter 框架本身崩了。其实这行日志通常只是告诉你Dart 侧有一个异步任务抛了异常但没人 catch。它常见于网络请求超时、JSON 解析失败、或者在Future里访问了空对象的属性。我推荐在入口文件里加一个全局兜底void main() { runZonedGuarded(() { WidgetsFlutterBinding.ensureInitialized(); runApp(const MyApp()); }, (error, stack) { // 这里打印、上报或者做降级处理 debugPrint(unhandled error: $error); debugPrintStack(stackTrace: stack); }); }加上这个之后即使有漏网的异步异常你至少能看到完整堆栈而不是只有一个dart_vm_initializer.cc的编号。很多项目因为这个兜底定位 bug 的时间从“几天”变成“几分钟”。6.5 组件通信越复杂越要关注“命题流动方向”Flutter 组件通信的方式很多回调、InheritedWidget、Provider、Stream、事件总线甚至全局单例。用法网上都有但容易忽略的是方向性。我的经验是先想清楚哪个组件负责保存命题哪个组件只是监听命题再选通信方式。如果父子关系很浅回调最直接如果跨层共享Provider或InheritedWidget合适如果涉及跨模块甚至跨页面广播Stream更优雅。尽量别用“全局单例保存一堆状态”来解决通信问题那会导致所有组件都在监听一个巨大的真值表命题之间的依赖关系乱成一团改一个变量影响一片页面。状态该局部就局部该共享再共享这是命题逻辑里“最小依赖集”思想的工程化表达。最后聊点掏心窝的。我在实际项目里最受益的一步是每次写页面之前先把“页面有哪几种状态”写在注释里而不是直接开写 Scaffold。那些状态组合起来就是一张真值表页面里每个控件该不该出现本质都是对这张表的查询。你现在去翻很多优秀的开源项目会发现它们的 build 方法虽然长但里面几乎没有三层以上的 if 嵌套原因就在这。试着把你最近一个页面里的所有条件整理成命题表达式你会回来感谢离散数学的。
返回列表