做鸿蒙应用这段时间,我越来越觉得只守着单端技术栈是不够的。新项目一开始就提了多端需求,手机、平板、智慧屏都要覆盖,原生各写一套显然不现实,于是我开始认真补Flutter这门课。今天是第一天,先把Dart语言的变量与基本类型吃透,顺手用了一个“星际指挥中心”的小模型把概念串起来。如果你也在评估Flutter,或者刚开始接触Dart,这篇内容基本就是一条完整的第一天路线,跟着一遍跑下来,变量和类型这一层就算落地了。
1. 跨端能力补课:为什么鸿蒙开发者要给自己加一门Flutter
很多做鸿蒙开发的朋友会疑惑,系统自己那一套ArkTS和ArkUI用得好好的,再去学Flutter是不是有点“自废武功”?我的看法是:这从来不是二选一的问题,而是给团队多一条交付路径。鸿蒙原生适合深度绑定系统能力的场景,但遇到要同时覆盖Android、iOS、Windows、Web这类需求时,Flutter一套代码就能打平,开发成本和维护成本差别非常大。尤其这两年多端统一的需求越来越频繁,Flutter作为跨端方案里的主流选择,值得投入。
Flutter和其他跨端方案最大的区别是渲染思路。它不依赖系统自带控件,而是自己用Skia引擎把UI画出来,也就是说同一套界面代码在不同设备上看到的细节几乎一致。这一点对讲究视觉一致性的应用特别重要。当然,这套机制的宿主语言是Dart,所有想在Flutter里写界面的人,第一步都是先把Dart基础打扎实。Day 1放在变量和基本类型上,是因为后面碰到的每一个Widget、每一个状态管理工具,本质上都在操作这些基础元素。
我见过不少新人一上来就抱着Widget目录猛啃,结果遇到状态更新和类型转换时一头雾水,最后回头补基础。与其绕路,不如第一天就把变和类型的逻辑理清楚。尤其要提醒的是,鸿蒙侧的ArkTS脱胎于TypeScript,类型思维和Dart有相似但也有明显差异,比如Dart的空安全写法就和TS不完全一样。所以哪怕你已经是熟手,也值得把Dart语法里那些“熟悉又陌生”的点重新过一遍。
再有,如果团队里同时有鸿蒙和跨端两条线,一个人掌握Flutter之后,能让排期灵活不少。我目前的做法是:原生能力优先走鸿蒙,通用业务界面用Flutter快速覆盖其他端,两边还可以通过桥接层互相调用。这个路线在真实项目里已经跑通了,前提就是跨端这条线得有人能接得住。接得住的前提,是Dart基础阶段别图快。
2. 用“星际指挥中心的总台看板”理解变量:var、类型标注、final与const
第一天学变量,最容易卡住的地方是声明方式太多。var、int、final、const、dynamic,看起来写法都差不多,背后的约束却完全不同。我试着用一个星际指挥中心的场景来理解,效果不错,分享在这里。
假设指挥中心总台上有一排看板,每个看板都贴了一个标签,标签写着“飞船数量”“指挥官代号”“当前燃料占比”。变量就是这些看板上的格子,格子能存什么内容,取决于标签怎么写。var的意思是你告诉值班员:“先帮我贴个标签,具体类型根据放进去的东西自动定。”比如放进去一个整数12,这个格子就自动变成整数格;以后再往里面塞字符串,值班员就会拦你,因为格子类型已经固定了。
这就是Dart里var最核心的机制:自动推断,但推断完成之后类型就锁死了。很多人误以为var是“随便存什么类型”,那其实是dynamic的活。dynamic更像是没贴标签的收件箱,里面放什么都没人管,取出来的时候要自己负责。我建议第一天的代码里尽量少碰dynamic,等理解清楚类型系统之后再去用它处理特殊场景,比如和JavaScript对象互操作时。
显式类型标注则完全相反。int starshipCount = 12;这种写法等于直接把格子类型写在标签上,值班员一眼就知道该放什么。在团队协作、接口边界、JSON解析结果建模这些场景下,显式标注可读性更好,也更容易触发编译期检查。Dart是静态类型语言,类型写明白了,很多隐患在编译阶段就能被揪出来,而不是等到运行期才崩。
final和const这两兄弟第一天特别容易混。我的记忆方式是:final是“只能写一次的值”,它允许在运行时才确定。比如从配置文件里读取指挥官编号,程序跑起来才知道具体值是多少,这种情况用final正合适。const比final更严格,它要求值在编译期就确定,是一个真正“出厂定死”的常量。拿时间举例:const time = DateTime.now()直接编译报错,因为程序还没运行,now()不可能在编译期确定;但final time = DateTime.now()就完全合法。
放一份代码感受一下差别:
void main() { // 编译期就能确定的常量,用 const const string baseName = '星环指挥中心'; const int maxFleetSize = 50; // 运行期才确定的值,用 final final int commanderId = int.parse('711'); // 自动推断:var 推断后固定为 int var starshipCount = 12; // 显式类型标注 double fuelRatio = 0.78; print('$baseName 当前指挥官ID: $commanderId'); print('飞船数量: $starshipCount, 燃料: $fuelRatio'); }这里commanderId用final是刻意设计的。因为int.parse('711')虽然结果看起来固定,但函数的调用发生在运行时,Dart的编译器在编译阶段无法验证这个值,所以不能标成const。很多初学者在这里报错后一脸懵,其实就是没分清“数值不可变”和“运行期才拿到数值”这两件事。
我第一天做练习时给自己定了一条规则:所有声明先问自己三个问题——这个值会重新赋值吗?赋值发生在编译期还是运行期?有没有可能为空?三个问题答完,用哪个关键字基本就有答案了。等这套判断成为肌肉记忆,再看别人代码时一眼就能读出作者的意图,这对后面维护旧项目特别有用。
3. 基本类型逐个过:int、double、String、bool,以及类型转换的“通信协议”
Dart的基本类型没有那么多花哨的,最常用的就是int、double、String、bool这四样。我依然把它们放进星际指挥中心的场景里,每个类型都有很自然的对应物。
int用来表示整数值,典型场景是飞船数量、编队编号、能量块整数库存。double用来表示小数,燃料百分比、地图坐标、护盾充能进度都归它管。String就是文本,指挥官指令、飞船名称、日志消息全是字符串。bool只有两种情况,开或关、警戒或正常、已出发或待命,比如isAlert、isReady这些标志位。
需要特别注意的是数字和字符串之间的转换。跨端开发里经常要接配置文件、命令行参数、网络返回数据,这些数据在传输层几乎都是字符串形态,到了代码里得转换成本地类型才能参与计算。Dart的转换API很直接:
void main() { // 字符串 -> 数字 String fuelText = '78.5'; double fuelRatio = double.parse(fuelText); // 数字 -> 字符串 int fleetSize = 12; String countText = fleetSize.toString(); // 字符串 -> 整数 int energy = int.parse('860'); // 组合变量做强转类型 String detail = '编队$countText,能量$energy,燃料$fuelRatio'; print(detail); }代码里的'编队$countText'这种写法是字符串插值,用$变量名把值嵌进字符串里。这个语法在Flutter开发里出现频率极高,几乎每个界面都要用,第一天就要练熟。
类型转换里有一个容易忽略的点:double.parse和int.parse对字符串格式要求严格,遇到非法输入会直接抛异常。比如int.parse('12.5')会报错,int.parse('abc')也会报错。真实项目里从外部拿到的数据未必规整,稳妥的做法是先判断再解析,或者用tryParse系列方法。int.tryParse解析失败时返回null而不是抛异常,配合空安全语法处理起来非常顺。
再说说Dart的空安全。现代Dart默认是空安全(sound null safety),意思是你不能把一个值随意赋成null,除非你明确说这个类型允许为空。声明可空类型的写法是在类型后面加问号:
void main() { int? backupCommanderId; // 备用指挥官可能没有,所以允许为空 backupCommanderId = null; // 合法 int normalCommanderId = 1; // normalCommanderId = null; // 编译报错 // 使用可空变量时,需要处理null if (backupCommanderId != null) { print('备用ID: $backupCommanderId'); } }这套逻辑和TypeScript的严格空值检查很像,但也有一些细节差异。比如Dart里有!非空断言、?.空安全访问、??双问号合并运算符。第一天不需要全掌握,但必需要知道:Dart在编译期就把空值问题管起来了,你不能像以前在JavaScript里那样随便把null传来传去。
我建议第一天把int、double、String、bool的典型用法各写一遍,再写几个转换场景。四类基本类型就像是指挥中心的四大通信协议:整数协议管数量、浮点协议管比例、字符串协议管指令、布尔协议管开关。通信协议不乱,后面整个指挥系统才转得起来。
4. 集合类型:把舰队编组、物资清单和识别ID放进代码
单靠基本类型只能表达一个值,现实场景里数据往往是一组一组出现的。Dart里最常用的三个集合类型是List、Map和Set,它们各有各的“性格”,第一天把它们认清楚,后面处理列表数据、JSON结构、状态管理都会顺畅很多。
List是有序的列表,想象成指挥中心的舰队编组:第一艘、第二艘、第三艘,顺序有意义,允许重复,可以通过下标访问。声明和操作方式如下:
void main() { // 自动推断为 List<String> var fleet = ['黎明号', '曙光号', '远望号']; // 添加新舰船 fleet.add('破晓号'); // 按下标读取 print('旗舰: ${fleet[0]}'); // 遍历编队 for (var ship in fleet) { print('检测到舰船: $ship'); } // 获取长度 print('编队规模: ${fleet.length}'); }Map是键值对集合,对应指挥中心的物资清单:每一种物资对应一个数值,比如“氧气: 85%”“能量块: 600块”。它的特点是通过键来读取值,键不能重复,值的类型可以固定。用代码描述就是:
void main() { // 自动推断为 Map<String, int> var supplies = { '氧气': 85, '能量块': 600, }; // 添加/修改条目 supplies['饮用水'] = 1200; supplies['氧气'] = 78; // 读取 print('当前氧气: ${supplies['氧气']}'); // 遍历 supplies.forEach((key, value) { print('物资: $key, 余量: $value'); }); }Set是去重集合,适合存放需要“唯一性”的识别ID。比如侦察单位的编号,理论上不应该重复,用Set存就可以在插入阶段自动去掉重复值。它和List最大的区别就是没有顺序位置的概念,也不允许重复元素。判断一个元素是否存在是Set的长项:
void main() { var scoutIds = {'SC-001', 'SC-002', 'SC-003'}; // 尝试添加重复值 scoutIds.add('SC-001'); // 不会改变集合内容 // 判断是否存在 print('是否存在SC-002: ${scoutIds.contains('SC-002')}'); for (var id in scoutIds) { print('侦察单位: $id'); } }三个集合怎么选?我的判断标准很简单:需要保持顺序、允许重复,用List;需要通过键找值、像查字典一样,用Map;只关心“有没有”、不能重复,用Set。第一天的练习建议把同一份数据分别塞进三种集合,打印出来观察它们的表现差异,尤其注意Set自动去重的行为。
还有一个初学者要注意的点:集合变量本身用var声明后,类型是整个集合的类型。比如var fleet = ['黎明号']推断为List<String>,后面就不能往里加数字了。如果需要明确指定集合里元素的类型,也可以显式标注List<String> fleet = ['黎明号'];,这样意图更清楚。
我对集合的学习建议是:别急着背API,先把“有序列表、键值映射、唯一集合”这三个心智模型记住。写代码时多问自己“我现在处理的是编队清单、物资字典还是侦察ID集合”,选类型的思路自然就清晰了。集合类型在Flutter里简直是日常,一个页面的列表数据就是List,一组配置项就是Map,去重状态就是Set。
5. 组装“星际指挥中心”模型:第一天知识点的整合演练
单项知识点过完,最好做一个能把它们全串起来的小项目。第一天的任务不需要涉及界面,只要用纯Dart写一个“星际指挥中心”模型,把前面所有内容合到一个main函数里。这个模型的意义在于:学着把一个抽象场景拆成具体的数据结构,这是后续所有Flutter项目的通用能力。
先梳理需求。一个指挥中心至少要包含这些信息:基地名称、指挥官编号(运行期确定)、编队列表、物资清单、侦察单位集合、是否警戒状态、燃料比例。其中基地名称是固定不变的,适合用const;指挥官编号从外部传入或解析得到,适合用final;动态变化的运行数据用var或显式类型标注。我把这些信息组织成一份完整的代码:
void main() { // 编译期常量:基地名称与上限 const String baseName = '星环指挥中心'; const int maxFleetSize = 50; // 运行期确定:以String形式读取ID final int commanderId = int.parse('711'); // 动态运行数据 String commanderName = '林指挥官'; int starshipCount = 12; double fuelRatio = 0.78; bool isAlert = false; // 集合:舰队、物资、侦察ID var fleet = ['黎明号', '曙光号', '远望号']; var supplies = {'氧气': 85, '能量块': 600, '饮用水': 1200}; var scoutIds = {'SC-001', 'SC-002', 'SC-003'}; // 模拟添加新舰船 fleet.add('破晓号'); starshipCount = fleet.length; // 打印指挥简报 String report = ''' ==== $baseName 指挥简报 ==== 指挥官ID: $commanderId 指挥官姓名: $commanderName 当前警戒状态: $isAlert 编队规模: $starshipCount(上限$maxFleetSize) 燃料比例: ${fuelRatio * 100}% 舰队列表: $fleet 物资清单: $supplies 侦察单位: $scoutIds '''; print(report); }这段代码的执行结果就是一份完整的指挥简报。运行这个模型后,你会发现之前零散的变量声明、类型转换、集合操作全都出现在了真实场景里,而且每一行都可以解释为什么这么写。比如fuelRatio是double,打印时乘以100变成百分比数值,只是算术运算不是类型转换,但恰好让你感受到“数值是怎么从一种形式变成另一种形式的”。
如果想进一步贴近空安全,可以给模型加一个“备用指挥官”的概念:
void main() { int? backupCommanderId; // 模拟从配置读取,可能为空 String? configValue = '823'; if (configValue != null) { backupCommanderId = int.parse(configValue); } // 使用 ?. 安全访问 String backupInfo = backupCommanderId != null ? '备用ID: $backupCommanderId' : '备用指挥官尚未指定'; print(backupInfo); }这个片段大家先理解思路就好,不用急着背语法。关键是我要让backupCommanderId这个变量“可能为空”这一特性在模型里有意义,而不是为了用空安全而用。等第二天学Flutter的界面状态时,你会发现这种可空变量几乎无处不在:数据还没加载完时为null,加载完成后填充实际值,界面就根据这个值决定展示“加载中”还是“数据内容”。
我练完这个模型后,接下来的进阶方向其实很明确。模型里的数据是写死的,下一步就是从外部JSON读取真实数据填充到这个模型里,再把模型联动到Flutter的Text、ListView等基础组件上。但那是后面几天的事,第一天只要能把“需求转变量声明”“场景转集合结构”这两件事跑通,就已经比只刷语法视频强太多。
6. 第一天最容易踩的三个坑,我替你们先踩了
学Dart的第一天,语法本身不难,真正让人抓狂的是那些“看起来对、跑起来报错”的时刻。我在练习“星际指挥中心”模型的过程中踩了几个坑,挑最有代表性的三个分享出来,都是过来人经验,能给新手省不少时间。
第一个坑:误以为var等于动态类型。从JavaScript或Python转过来的开发者最容易被这个误导。在Dart里,var是在声明那一刻由编译器推断类型,推断完成后就固定了。我试过这样写:
void main() { var status = '警戒'; // 推断为 String status = 1; // 编译报错,不能把 int 赋值给 String }报错信息会明确告诉你类型不匹配。真正做到“想放什么就放什么”的是dynamic,但代价是失去编译期类型检查,能不用尽量不用。解决办法很简单:看到var就当成“编译器帮我写好了类型”,一旦看到后续赋值类型不同,说明代码设计有问题。
第二个坑:const误用在运行期才能确定的值上。我一开始写模型的时候,想把commanderId标成const,因为直觉上觉得ID值固定。但int.parse('711')是运行期函数调用,Dart的const要求编译期就能确定值,于是直接编译失败。后来我把ID换成final,问题就消失了。这里我的经验是:判断标准不要看“值会不会变”,而要看“编译期能不能确定”。命令行参数、网络请求、从配置读取、调用DateTime.now(),这些一律不能加const;只有字面量、常量表达式、基础类型字面量才能用const。
第三个坑:空安全语法和TypeScript不完全一样。鸿蒙的ArkTS基于TypeScript,在TS里处理null有?和??,Dart也有类似概念,但具体写法需要注意。比如Dart声明可空用int?,使用可空变量时,编译器强制要求先判空才能当非空用:
void main() { int? value = int.tryParse('12'); // 直接 print(value + 1) 会报错,因为 value 可能是 null if (value != null) { print(value + 1); // OK,判空之后可以使用 } }比较有意思的是,Dart的流程分析足够聪明,一旦你在if里判了空,紧接着的代码块内它会自动把你当成非空类型使用,不用额外做类型断言。这和Kotlin的智能类型转换有些相似。第一天容易踩的坑就是忘了判空直接操作,报错后一脸困惑。其实只要记住一个原则:可空变量在使用前必须处理null的可能性,编译器的报错是在帮你堵漏洞。
我还想额外提一个和工具链相关的点。如果你当前的开发环境还没有安装Flutter SDK,只装了Android Studio或普通的Dart插件,也完全可以跑通今天的所有示例。纯Dart脚本用dart run命令就能执行,不需要模拟器和设备。我在练习时就是先建了一个纯Dart命令行项目,把所有变量、集合、模型代码丢进去反复跑,等第二天再切到Flutter工程去看界面渲染。这个顺序让我把精力全部聚焦在语言基础本身,没有被繁杂的工程配置干扰。
提示:第一天不贪多,手敲一遍示例比看十遍教程管用。重点是形成自己的判断链路——先想清楚“这个值会不会变”“编译期能否确定”“会不会为空”,再决定用什么关键字。
把这三个坑记住,你第一天的Dart基础就算真正落地了。接下来几天自然过渡到控制流、函数、面向对象,然后再进入Flutter的Widget世界。每天学一点,用一个小模型串起来,用不了一周,你就能写出第一版真正的Flutter界面。