Ubuntu Server 网络配置实战：Netplan 配置 WiFi 与静态 IP 详解
在 Ubuntu Server（特别是 18.04/20.04/22.04+ 版本）中，传统的 /etc/network/interfaces 配置文件已逐渐被 Netplan 取代。对于使用 USB 无线网卡或需要固定服务器 IP 的场景，掌握 Netplan 的配置至关重要。本文将手把手教你如何通过命令行完成 WiFi 连接及静态 IP 设置，并深度解析配置文件中每一个参数的含义。第一步：确认无线网卡名称在开始之前，我们需要知道系统识别到的无线网卡接口名称。USB 网卡通常以 wlx 开头。打开终端，输入以下命令：ip link show输出示例分析：1: lo: <LOOPBACK,UP...> 2: enp1s0: <BROADCAST,MULTICAST...> <-- 这是有线网卡 3: wlx485f082e470d: <BROADCAST,MULTICAST...> <-- 这就是我们的 USB 无线网卡注意：请记下第 3 行的接口名称（例如 wlx485f082e470d），后续配置必须完全一致。第二步：编辑 Netplan 配置文件Ubuntu 的网络配置文件位于 /etc/netplan/ 目录下，通常是 .yaml 格式。1. 打开编辑器使用 nano 编辑器打开文件（文件名可能略有不同，常见为 01-network-manager-all.yaml 或 50-cloud-init.yaml）：sudo nano /etc/netplan/01-network-manager-all.yaml2. 写入配置代码清空原有内容，根据你的需求选择以下两种配置之一。方案 A：动态获取 IP (DHCP)适用于普通上网，IP 地址由路由器自动分配。network: version: 2 renderer: networkd wifis: wlx485f082e470d: # 替换为你的实际网卡名称 dhcp4: true access-points: "Your_WiFi_Name": # 替换为你的 WiFi 名称 (SSID) password: "Your_WiFi_Password" # 替换为你的 WiFi 密码方案 B：配置静态 IP (Static IP)适用于服务器环境，确保 IP 地址固定不变，方便远程管理。network: version: 2 renderer: networkd wifis: wlx485f082e470d: # 替换为你的实际网卡名称 dhcp4: no # 关闭自动获取 IP addresses: - 192.168.1.200/24 # 你想要设置的固定 IP 和子网掩码 routes: - to: default via: 192.168.1.1 # 你的路由器网关地址 nameservers: addresses: [8.8.8.8, 114.114.114.114] # DNS 服务器地址 access-points: "Your_WiFi_Name": password: "Your_WiFi_Password"3. 保存并退出按 Ctrl + O 然后回车保存。按 Ctrl + X 退出编辑器。第三步：应用配置配置写好后，需要让 Netplan 重新生成后端配置并应用：sudo netplan apply如果没有报错，说明配置成功。你可以使用 ping baidu.com 测试网络是否通畅，或使用 ip addr show 查看 IP 是否已变更。核心参数详解理解每个参数的含义，能让你在排查故障时事半功倍。1. 基础架构参数version: 2含义：指定 Netplan 配置文件的语法版本。目前 Ubuntu 主要使用 Version 2，Version 1 已被废弃。renderer: networkd含义：指定由哪个后端程序来管理网络。选项：networkd：即 systemd-networkd。Server 版（无图形界面）的标准选择，轻量高效。NetworkManager：Desktop 版（有图形界面）的标准选择。wifis:含义：定义无线网络接口块。如果是有线网络，则使用 ethernets:。2. 设备与连接参数**wlx485f082e470d** (接口名称)含义：具体的物理设备标识符。对于 USB 网卡，Ubuntu 倾向于使用基于 MAC 地址的可预测命名。这个名字必须和 ip link show 查出来的一模一样。**access-points**含义：定义要连接的 WiFi 热点列表。**"Your_WiFi_Name"** (SSID)细节：如果 WiFi 名称包含空格或特殊字符，必须加双引号。建议养成始终加引号的习惯。**password**含义：WiFi 的预共享密钥（PSK）。3. IP 地址配置参数 (重点)**dhcp4: true / no**true：开启 IPv4 动态主机配置协议，自动从路由器获取 IP。no：关闭自动获取，准备手动指定 IP。**addresses** (静态 IP 专用)格式：[IP地址/子网掩码位数]解释：例如 192.168.1.200/24。这里的 /24 等同于子网掩码 255.255.255.0。**routes** (静态 IP 专用)含义：定义路由规则。解释：to: default 表示默认路由（即所有不知道去哪的数据包都走这里），via: 192.168.1.1 指定下一跳网关（通常是路由器地址）。如果不配这个，你只能连局域网，上不了互联网。**nameservers** (静态 IP 专用)含义：DNS 域名解析服务器。解释：如果不配这个，你能 ping 通 IP 地址（如 8.8.8.8），但无法解析域名（如 www.baidu.com）。推荐使用 8.8.8.8 (Google) 或 114.114.114.114 (国内通用)。避坑指南缩进是灵魂：YAML 对缩进非常敏感。层级关系必须通过空格体现（通常每级 2 个空格），严禁使用 Tab 键，否则会导致解析失败。服务未启动：如果执行 netplan apply 报错提示 systemd-networkd is not running，请先手动启动服务：驱动问题：如果 ip link show 根本看不到 wlx... 设备，说明无线网卡的驱动程序没装好，这时候配置网络是没用的，需要先解决驱动问题。
在折腾各类“纯血”Linux 发行版之前，理清主板固件的底层逻辑是避免安装失败、系统崩溃的第一步。针对当前主流的“MBR 分区表 + 传统 BIOS 引导”老旧硬件或特定需求场景，前置配置的容错率极低。以下是为你整理的标准前置操作配置指南，帮助你顺利跨越这道门槛。一、 核心 BIOS 设置项与操作指南1. CSM（兼容性支持模块）：必须保持“启动”在 MBR + 传统模式下，CSM 是主板模拟传统 BIOS 中断调用、识别 MBR 引导记录的“生命线”。切勿听信“安装 Linux 必须关闭 CSM”的绝对化言论，那是针对 UEFI + GPT 环境的建议。一旦关闭 CSM，主板将仅支持 UEFI 引导，你的 MBR 硬盘将直接“隐身”，导致无法安装或无法启动。2. SATA 模式：从 RST 切换至 AHCI这是安装 Linux 最容易踩坑的环节。Intel RST（快速存储技术）属于专有协议，绝大多数 Linux 发行版的内核默认不包含 RST 驱动，这会导致安装程序在分区界面提示“找不到磁盘”。必须在 BIOS 中将存储模式修改为通用的 AHCI 标准，Linux 才能顺利接管硬盘读写。3. Secure Boot（安全启动）：无需手动干预在 CSM 开启的传统模式下，Secure Boot 会被主板自动隐藏并强制处于“禁用”状态。因此，你不需要像 UEFI 环境那样刻意去寻找并关闭它，它在此场景下对 Linux 安装没有任何阻碍。4. 启动介质选择：认准“无 UEFI 前缀”选项制作好 Linux 启动 U 盘后，在 BIOS 的启动顺序（Boot Priority）或快捷启动菜单（F12/F8）中，务必选择不带“UEFI:”前缀的 U 盘选项。带有 UEFI 前缀的选项会强制主板尝试以 GPT 模式引导，这在 MBR 环境下会导致启动失败或进入错误的安装流程。二、 底层逻辑：为什么要这样配置？MBR 与 CSM 的强绑定MBR（主引导记录）是一种古老的分区表格式，最大仅支持 2TB 磁盘。它依赖传统 BIOS 的 16 位实模式中断来加载操作系统的引导加载程序（如 GRUB）。CSM 正是提供这一模拟环境的模块，没有它，现代主板就无法理解 MBR 的引导指令。AHCI 是 Linux 的通用通行证Linux 内核的开源社区对通用标准的支持极为完善。AHCI 作为 SATA 控制器的开放标准，拥有完美的内核级驱动支持。放弃专有的 RST 转向 AHCI，本质上是为了让操作系统能够直接与存储硬件进行标准化通信，从而避免“无盘可装”的尴尬。三、 核心关键词速记核心操作：CSM 保持启动、SATA 改 AHCI、Legacy 引导避坑要点：不关 CSM、无视 Secure Boot、选对非 UEFI 启动项技术原理：MBR 依赖 CSM 模拟、Linux 依赖 AHCI 驱动、传统模式绕过安全启动掌握以上前置配置，你的 MBR 传统模式主机就已经具备了安装主流 Linux 发行版的完美土壤。接下来，只需准备好对应的 Legacy 引导镜像，即可开始你的开源之旅。
使用 AutoSSH 打造坚如磐石的内网穿透隧道
在远程运维和开发场景中，我们经常面临一个痛点：家里的电脑或公司的内网服务器没有公网 IP，想要从外部随时访问该怎么办？传统的 DDNS + 端口映射不仅配置繁琐，还会将内网端口直接暴露在公网，极易招致恶意扫描和攻击。最安全、最优雅的方案，莫过于利用一台公网 VPS 作为跳板机，通过 SSH 反向隧道实现“隐形”的内网穿透。但普通的 SSH 隧道有一个致命弱点：一旦遇到网络波动，连接就会“假死”，进程还在但数据不通。今天，我们就来聊聊如何使用 autossh 打造一个“打不死”的 SSH 隧道，实现稳定、安全的内网穿透。架构拓扑：打造安全的“隐形通道”我们的整体架构非常清晰：[外部设备] --(SSH加密连接)--> [公网跳板机] --(本地回环转发)--> [内网目标主机]这种架构有两个核心优势：隐蔽性：公网跳板机对外只开放常规的 SSH 端口，不暴露任何额外的业务端口。安全性：内网目标主机的服务只监听本地回环地址（127.0.0.1），对局域网内的其他设备完全不可见，所有流量都必须经过加密的 SSH 隧道。第一步：环境准备与密钥生成假设我们已经拥有一台公网跳板机（IP为 <公网跳板机IP>，SSH端口为 <跳板机SSH端口>），以及一台位于内网的目标主机（用户名为 <内网主机用户名>）。首先，我们需要在内网目标主机上安装必要的软件，并生成一把专门用于建立隧道的 SSH 密钥（不要与日常登录的密钥混用）：# 更新软件源并安装 openssh-server 和 autossh sudo apt update sudo apt install openssh-server autossh # 生成专用的隧道密钥（一路回车，不设置密码） ssh-keygen -t ed25519 -f ~/.ssh/id_tunnel -N "" -C "autossh-tunnel"接下来，将这把密钥的公钥上传到公网跳板机：ssh-copy-id -i ~/.ssh/id_tunnel.pub -p <跳板机SSH端口> <跳板机用户名>@<公网跳板机IP>第二步：配置 Systemd 守护进程为了让隧道在系统开机时自动启动，并在意外断开时自动重连，我们使用 systemd 来托管 autossh。在内网目标主机上创建服务配置文件：sudo nano /etc/systemd/system/autossh-tunnel.service将以下内容写入文件中（注意替换对应的参数）：[Unit] Description=AutoSSH Reverse Tunnel After=network-online.target ssh.service Wants=network-online.target [Service] User=<内网主机用户名> Type=simple # 核心参数解析： # -M 0: 禁用 autossh 自身的监控端口，改用 SSH 原生的心跳保活机制 # -N: 不执行远程命令，仅建立隧道 # -R 127.0.0.1:<跳板机映射端口>:localhost:22: # 将跳板机的指定端口绑定在 127.0.0.1，确保公网无法直接扫描 Environment="AUTOSSH_GATETIME=0" ExecStart=/usr/bin/autossh -M 0 -N \ -o "ServerAliveInterval=30" \ -o "ServerAliveCountMax=3" \ -o "ExitOnForwardFailure=yes" \ -o "StrictHostKeyChecking=no" \ -p <跳板机SSH端口> \ -i /home/<内网主机用户名>/.ssh/id_tunnel \ <跳板机用户名>@<公网跳板机IP> # 崩溃或断开后自动重启 Restart=always RestartSec=10 [Install] WantedBy=multi-user.target第三步：启动服务与验证配置完成后，启动服务并设置开机自启：sudo systemctl enable --now autossh-tunnel.service查看服务状态，确认是否正常运行：systemctl status autossh-tunnel.service如果看到 Active: active (running) 且没有 Permission denied 等报错，说明隧道已经成功建立。此时，隧道已经将内网目标主机的 22 端口，安全地投射到了公网跳板机的 <跳板机映射端口> 上。第四步：如何从外部连接？现在，无论你身处何地（公司、咖啡厅或旅途中），只要先通过 SSH 登录到公网跳板机，就可以轻松跳转到内网主机：# 1. 先登录跳板机 ssh -p <跳板机SSH端口> <跳板机用户名>@<公网跳板机IP> # 2. 在跳板机内，通过本地回环地址跳转到内网主机 ssh -p <跳板机映射端口> <内网主机用户名>@127.0.0.1总结通过 autossh + systemd + 反向隧道的组合，我们不仅实现了无公网 IP 环境下的远程访问，还保证了连接的极高稳定性与安全性。这套方案配置一次，即可长期稳定运行，是家庭实验室和远程运维的绝佳选择。
波浪效果 class SineWaveWidget extends StatefulWidget { final double amplitude; // 振幅（波浪的高度） final double frequency; // 频率（波浪的密集程度） final Color color; // 波浪颜色 final Duration duration; // 流动一圈的周期 const SineWaveWidget({ super.key, this.amplitude = 20.0, this.frequency = 2.0, this.color = Colors.blueAccent, this.duration = const Duration(seconds: 1), }); @override State<SineWaveWidget> createState() => _SineWaveWidgetState(); } class _SineWaveWidgetState extends State<SineWaveWidget> with SingleTickerProviderStateMixin { late AnimationController _controller; bool _isForward = true; @override void initState() { super.initState(); // 创建一个无限循环的动画控制器，用于驱动波浪的相位变化 _controller = AnimationController( vsync: this, duration: widget.duration, lowerBound: 0.0, upperBound: 2 * pi, )..repeat(); // 自动无限循环 } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return GestureDetector( onTap: () { _isForward = !_isForward; }, child: AnimatedBuilder( animation: _controller /*.drive(Tween(begin: 0, end: 2 * pi))*/, // 将 0~1 的进度映射为 0~2π 的相位 builder: (context, child) { return CustomPaint( size: Size.infinite, // 填满父容器 painter: SineWavePainter( amplitude: widget.amplitude, frequency: widget.frequency, color: widget.color, phase: _controller.value * (_isForward ? 1 : -1), ), ); }, ), ); } } class SineWavePainter extends CustomPainter { final double amplitude; final double frequency; final Color color; final double phase; SineWavePainter({ required this.amplitude, required this.frequency, required this.color, required this.phase, }); @override void paint(Canvas canvas, Size size) { // final paint = Paint() // ..color = color // ..style = PaintingStyle.fill // ..blendMode = BlendMode.xor; // // final path = Path(); // // // 将波浪的基准线设置在垂直中心 // final centerY = size.height / 2; // // // 1. 起点：从左下角开始 // path.moveTo(0, size.height); // // // 2. 绘制正弦曲线 // // 遍历画布的每一个像素宽度 // for (double x = 0; x <= size.width; x++) { // // 核心公式：y = A * sin(ω * x + φ) // // 乘以 2π 是为了让 frequency 代表“画布宽度内包含的完整波浪数量” // final y = centerY + // amplitude * sin((x / size.width) * frequency * 2 * pi + phase); // path.lineTo(x, y); // } // // // 3. 闭合路径：连接到右下角，再回到左下角，形成封闭的填充区域 // path.lineTo(size.width, size.height); // path.close(); // // final path2 = Path(); // path2.moveTo(0, size.height); // for (double x = 0; x <= size.width; x++) { // final y = centerY + // amplitude * sin((x / size.width) * frequency * 2 * pi + (phase + pi)); // path2.lineTo(x, y); // } // path2.lineTo(size.width, size.height); // path2.close(); // // // 4. 绘制到画布上 // canvas.drawPath(path, paint); // canvas.drawPath(path2, paint); final paint = Paint() ..color = color ..style = PaintingStyle.fill ..blendMode = BlendMode.multiply; final centerY = size.height / 2; final omega = frequency * 2 * pi / size.width; // 将波浪划分为固定的段数（例如 16 段），确保动画循环时首尾完美衔接 final segments = (frequency * 4).toInt(); final step = size.width / segments; for (int wave = 0; wave < 2; wave++) { final currentPhase = phase + (wave * pi); final path = Path(); // 起点：左下角 path.moveTo(0, size.height); // 遍历每个分段点 for (int i = 0; i <= segments; i++) { final x = i * step; final y = centerY + amplitude * sin(omega * x + currentPhase); if (i == 0) { path.lineTo(x, y); } else { // 前一个点的坐标和 Y 值 final prevX = (i - 1) * step; final prevY = centerY + amplitude * sin(omega * prevX + currentPhase); // 计算前一个点的切线斜率（导数） final prevSlope = amplitude * omega * cos(omega * prevX + currentPhase); // 计算控制点（基于前一点的切线推算） final controlX = prevX + step / 2; final controlY = prevY + (prevSlope * step) / 2; // 使用二次贝塞尔曲线连接 path.quadraticBezierTo(controlX, controlY, x, y); } } // 严格闭合路径：确保右下角和左下角完美贴合 path.lineTo(size.width, size.height); path.close(); canvas.drawPath(path, paint); } } @override bool shouldRepaint(covariant SineWavePainter oldDelegate) { // 只要相位（phase）发生变化，就重绘（用于实现流动动画） return oldDelegate.phase != phase; } }QQ消息拖拽效果 class QqDragBubble extends StatefulWidget { final double initialRadius; // 初始圆半径 final double maxDragDistance; // 最大拖拽断裂距离 final Color color; const QqDragBubble({ super.key, this.initialRadius = 15.0, this.maxDragDistance = 100.0, this.color = Colors.red, }); @override State<QqDragBubble> createState() => _QqDragBubbleState(); } class _QqDragBubbleState extends State<QqDragBubble> with SingleTickerProviderStateMixin { Offset _dragOffset = Offset.zero; // 手指拖拽位置 bool _isDragging = false; bool _isBurst = false; // 是否已断裂 late AnimationController _burstController; late Animation<double> _burstAnimation; @override void initState() { super.initState(); _burstController = AnimationController( vsync: this, duration: const Duration(milliseconds: 200), ); _burstAnimation = Tween<double>(begin: 1.0, end: 0.0).animate( CurvedAnimation(parent: _burstController, curve: Curves.easeOut), ); } @override void dispose() { _burstController.dispose(); super.dispose(); } void _onPanUpdate(DragUpdateDetails details) { if (_isBurst) return; setState(() { _isDragging = true; _dragOffset += details.delta; }); } void _onPanEnd(DragEndDetails details) { if (_isBurst) return; final distance = _dragOffset.distance; if (distance > widget.maxDragDistance) { // 超过临界值，触发断裂动画 setState(() => _isBurst = true); _burstController.forward(from: 0.0); } else { // 未超过临界值，回弹 setState(() { _isDragging = false; _dragOffset = Offset.zero; }); } } @override Widget build(BuildContext context) { return GestureDetector( onPanUpdate: _onPanUpdate, onPanEnd: _onPanEnd, child: AnimatedBuilder( animation: _burstAnimation, builder: (context, child) { return CustomPaint( size: Size(300, 300), // 给足绘制空间 painter: QqBubblePainter( color: widget.color, initialRadius: widget.initialRadius, dragOffset: _isDragging ? _dragOffset : Offset.zero, maxDragDistance: widget.maxDragDistance, burstScale: _isBurst ? _burstAnimation.value : 1.0, isDragging: _isDragging && !_isBurst, ), ); }, ), ); } } class QqBubblePainter extends CustomPainter { final Color color; final double initialRadius; final Offset dragOffset; final double maxDragDistance; final double burstScale; final bool isDragging; QqBubblePainter({ required this.color, required this.initialRadius, required this.dragOffset, required this.maxDragDistance, required this.burstScale, required this.isDragging, }); @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = color ..style = PaintingStyle.fill; // 起点圆心（屏幕中心） final startCenter = Offset(size.width / 2, size.height / 2); // 拖拽圆（终点）圆心 final endCenter = startCenter + dragOffset; // 计算两点间的距离和角度 final distance = (endCenter - startCenter).distance; final angle = atan2(endCenter.dy - startCenter.dy, endCenter.dx - startCenter.dx); // 1. 动态计算半径：拖拽越远，起点圆越小；终点圆保持原大小 double startRadius = initialRadius; double endRadius = initialRadius; if (isDragging && distance < maxDragDistance) { final ratio = distance / maxDragDistance; startRadius = initialRadius * (1 - ratio * 0.7); // 起点圆最多缩小 70% } // 2. 绘制起点圆（断裂后消失） if (!isDragging || distance <= maxDragDistance) { if (isDragging) { // 拖拽过程中起点圆随距离缩小 } else { startRadius = initialRadius; } canvas.drawCircle(startCenter, startRadius, paint); } // 3. 绘制终点圆（拖拽时跟随手指，断裂时播放缩放消失动画） if (isDragging) { canvas.drawCircle(endCenter, endRadius, paint); } else if (burstScale < 1.0) { // 断裂爆炸动画 canvas.drawCircle(endCenter, endRadius * burstScale, paint); } else { // 初始状态 canvas.drawCircle(startCenter, initialRadius, paint); } // 4. 绘制贝塞尔连接带 if (isDragging && distance < maxDragDistance && distance > 0) { final path = Path(); // 计算连接点坐标（基于三角函数） // 起点圆上的两个切点 final startX1 = startCenter.dx + startRadius * cos(angle + pi / 2); final startY1 = startCenter.dy + startRadius * sin(angle + pi / 2); final startX2 = startCenter.dx + startRadius * cos(angle - pi / 2); final startY2 = startCenter.dy + startRadius * sin(angle - pi / 2); // 终点圆上的两个切点 final endX1 = endCenter.dx + endRadius * cos(angle + pi / 2); final endY1 = endCenter.dy + endRadius * sin(angle + pi / 2); final endX2 = endCenter.dx + endRadius * cos(angle - pi / 2); final endY2 = endCenter.dy + endRadius * sin(angle - pi / 2); // 贝塞尔控制点（取两点连线的中点） final controlX = (startCenter.dx + endCenter.dx) / 2; final controlY = (startCenter.dy + endCenter.dy) / 2; // 绘制上半部分曲线 path.moveTo(startX1, startY1); path.quadraticBezierTo(controlX, controlY, endX1, endY1); // 绘制下半部分曲线（闭合路径） path.lineTo(endX2, endY2); path.quadraticBezierTo(controlX, controlY, startX2, startY2); path.close(); canvas.drawPath(path, paint); } } @override bool shouldRepaint(covariant QqBubblePainter oldDelegate) { return oldDelegate.dragOffset != dragOffset || oldDelegate.isDragging != isDragging || oldDelegate.burstScale != burstScale; } } 手写板带播放效果import 'package:flutter/material.dart'; // 1. 笔画数据模型（包含时间戳） class Stroke { final List<Offset> points; final DateTime startTime; final DateTime endTime; final Color color; final double strokeWidth; Stroke({ required this.points, required this.startTime, required this.endTime, this.color = Colors.black, this.strokeWidth = 5.0, }); int get duration => endTime.difference(startTime).inMilliseconds; } class HandwritingCanvas extends StatefulWidget { const HandwritingCanvas({super.key}); @override State<HandwritingCanvas> createState() => _HandwritingCanvasState(); } class _HandwritingCanvasState extends State<HandwritingCanvas> with SingleTickerProviderStateMixin { // 历史笔画 final List<Stroke> _strokes = []; // 正在绘制的笔画（使用 ValueNotifier 保证书写绝对流畅） final ValueNotifier<List<Offset>> _currentPointsNotifier = ValueNotifier([]); DateTime? _strokeStartTime; // 播放控制 late AnimationController _playbackController; late Animation<double> _playbackAnimation; bool _isPlaying = false; @override void initState() { super.initState(); _playbackController = AnimationController( vsync: this, duration: const Duration(seconds: 5), ); _playbackAnimation = Tween<double>(begin: 0.0, end: 1.0).animate( CurvedAnimation(parent: _playbackController, curve: Curves.linear), ); // 监听播放动画，触发画笔重绘 _playbackAnimation.addListener(() { if (_isPlaying) setState(() {}); }); } @override void dispose() { _playbackController.dispose(); _currentPointsNotifier.dispose(); super.dispose(); } // --- 录制逻辑 --- void _onPanStart(DragStartDetails details) { if (_isPlaying) return; // 播放时禁止书写 _strokeStartTime = DateTime.now(); _currentPointsNotifier.value = [details.localPosition]; } void _onPanUpdate(DragUpdateDetails details) { if (_isPlaying) return; _currentPointsNotifier.value = [ ..._currentPointsNotifier.value, details.localPosition ]; } void _onPanEnd(DragEndDetails details) { if (_isPlaying) return; if (_currentPointsNotifier.value.isNotEmpty && _strokeStartTime != null) { setState(() { _strokes.add(Stroke( points: List.from(_currentPointsNotifier.value), startTime: _strokeStartTime!, endTime: DateTime.now(), )); _currentPointsNotifier.value = []; }); } } // --- 播放逻辑 --- void _startPlayback() { if (_strokes.isEmpty || _isPlaying) return; setState(() => _isPlaying = true); final totalDuration = _strokes.last.endTime.difference(_strokes.first.startTime); _playbackController.duration = totalDuration; _playbackController.forward(from: 0.0).then((_) { setState(() => _isPlaying = false); }); } // --- 清除逻辑 --- void _clearCanvas() { if (_isPlaying) return; setState(() { _strokes.clear(); _currentPointsNotifier.value = []; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('完美手写板'), actions: [ IconButton( icon: const Icon(Icons.play_arrow), onPressed: _isPlaying ? null : _startPlayback, tooltip: '播放笔迹', ), IconButton( icon: const Icon(Icons.delete_outline), onPressed: _isPlaying ? null : _clearCanvas, tooltip: '清除画板', ), ], ), body: GestureDetector( onPanStart: _onPanStart, onPanUpdate: _onPanUpdate, onPanEnd: _onPanEnd, child: RepaintBoundary( child: CustomPaint( size: Size.infinite, painter: HandwritingPainter( strokes: _strokes, currentPoints: _currentPointsNotifier, isPlaying: _isPlaying, playbackProgress: _playbackAnimation.value, totalDurationMs: _strokes.isNotEmpty ? _strokes.last.endTime .difference(_strokes.first.startTime) .inMilliseconds : 0, ), ), ), ), ); } } class HandwritingPainter extends CustomPainter { final List<Stroke> strokes; final ValueNotifier<List<Offset>> currentPoints; final bool isPlaying; final double playbackProgress; final int totalDurationMs; HandwritingPainter({ required this.strokes, required this.currentPoints, required this.isPlaying, required this.playbackProgress, required this.totalDurationMs, }) : super(repaint: currentPoints); // 依然保留 ValueNotifier 驱动书写 @override void paint(Canvas canvas, Size size) { if (isPlaying) { // ================= 播放模式 ================= final currentTimeMs = (playbackProgress * totalDurationMs).toInt(); for (final stroke in strokes) { final elapsed = currentTimeMs - stroke.startTime.difference(strokes.first.startTime).inMilliseconds; if (elapsed <= 0) continue; // 还没到开始时间 final paint = Paint() ..color = stroke.color ..strokeWidth = stroke.strokeWidth ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round ..style = PaintingStyle.stroke; final path = Path(); path.moveTo(stroke.points.first.dx, stroke.points.first.dy); for (int i = 1; i < stroke.points.length; i++) { path.lineTo(stroke.points[i].dx, stroke.points[i].dy); } if (elapsed < stroke.duration) { // 正在播放中的笔画：按比例截取 final progress = elapsed / stroke.duration; final metric = path.computeMetrics().first; canvas.drawPath( metric.extractPath(0, metric.length * progress), paint); } else { // 已经播放完的笔画：完整绘制 canvas.drawPath(path, paint); } } } else { // ================= 书写模式 ================= // 绘制历史笔画 for (final stroke in strokes) { final paint = Paint() ..color = stroke.color ..strokeWidth = stroke.strokeWidth ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round ..style = PaintingStyle.stroke; final path = Path(); path.moveTo(stroke.points.first.dx, stroke.points.first.dy); for (int i = 1; i < stroke.points.length; i++) { path.lineTo(stroke.points[i].dx, stroke.points[i].dy); } canvas.drawPath(path, paint); } // 绘制正在写的笔画（通过 ValueNotifier 局部重绘，绝对流畅） final points = currentPoints.value; if (points.isNotEmpty) { final paint = Paint() ..color = Colors.black ..strokeWidth = 5.0 ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round ..style = PaintingStyle.stroke; final path = Path(); path.moveTo(points.first.dx, points.first.dy); for (int i = 1; i < points.length; i++) { path.lineTo(points[i].dx, points[i].dy); } canvas.drawPath(path, paint); } } } @override bool shouldRepaint(covariant HandwritingPainter oldDelegate) { // 播放时或者历史笔画变化时重绘 return isPlaying || oldDelegate.strokes != strokes; } } 抛物线动画效果
项目背景与需求在Flutter开发中，我们经常遇到需要实现多Tab栏列表的场景，每个Tab具有相似的结构（下拉刷新、上拉加载、列表展示），但数据类型和UI展示各不相同。本文将介绍如何使用模板方法模式来优雅地解决这个问题。模板方法模式简介模板方法模式是一种行为设计模式，它在超类中定义了一个算法的框架，允许子类在不修改结构的情况下重写特定步骤。在我们的场景中，每个Tab的加载流程（初始化→刷新→加载更多）是相同的，但具体的数据加载和UI展示是不同的。代码实现解析1. 抽象基类定义const int defaultPageSize = 10; abstract class TabData<T> { final String name; // tab名称 int total; // 数据数量 bool isSelected; // 选中状态 bool hasMore; // 是否有更多数据 List<T> items; // 数据列表 bool _isInited = false; // 是否初始化完成 final RefreshController controller; final Widget Function(BuildContext context,T item) itemBuilder; TabData({ required this.name, required this.itemBuilder, this.total = 0, this.isSelected = false, this.hasMore = true, this.items = const [] }) : controller = RefreshController(); int get pageSize => defaultPageSize; Future<List<T>> loadData(int page); void updateItems(List<T> newItems, bool isRefresh); Future<bool> init() async{ if (_isInited) { return true; } _isInited = true; await onRefresh(); return false; } Future<void> onRefresh() async { try { final data = await loadData(1); updateItems(data, true); hasMore = data.length >= pageSize; controller.refreshCompleted(); } catch (e) { controller.refreshFailed(); rethrow; } } Future<void> onLoadMore() async { if (!hasMore) { controller.loadNoData(); return; } try { final currentPage = (items.length / pageSize).ceil() + 1; final data = await loadData(currentPage); updateItems(data, false); hasMore = data.length >= pageSize; controller.loadComplete(); } catch (e) { controller.refreshFailed(); rethrow; } } void dispose() { controller.dispose(); } }设计要点：定义了完整的生命周期方法（init、onRefresh、onLoadMore）使用泛型T支持不同的数据类型封装了刷新控制器和分页逻辑提供itemBuilder回调实现UI定制化2. 具体子类实现点赞Tab实现：class LikeTabData extends TabData<LikeTabDataEntity>{ LikeTabData():super(name: "点赞",itemBuilder: (context,item){ return Text(item.name); }); @override Future<List<LikeTabDataEntity>> loadData(int page) async{ await Future.delayed(const Duration(seconds: 1)); return List.generate(pageSize, (index)=> LikeTabDataEntity(name: "$name 数据 ${page * pageSize + index}")); } @override void updateItems(List<LikeTabDataEntity> newItems, bool isRefresh) { items = isRefresh ? newItems : [...items, ...newItems]; total = items.length; } }评论Tab实现：class CommentTabData extends TabData<CommentTabDataEntity>{ CommentTabData():super(name: "评论",itemBuilder: (context,item) { Widget text () => Text(item.text,style: const TextStyle(color: Colors.white)); return Container( margin: const EdgeInsets.all(8), padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Colors.blueAccent, borderRadius: BorderRadius.circular(8), ), child: text(), ); }); @override Future<List<CommentTabDataEntity>> loadData(int page) async{ await Future.delayed(const Duration(seconds: 1)); return List.generate(pageSize, (index)=> CommentTabDataEntity(text: "$name 数据 ${page * pageSize + index}")); } @override void updateItems(List<CommentTabDataEntity> newItems, bool isRefresh) { items = isRefresh ? newItems : [...items, ...newItems]; total = items.length; } }子类只需实现：loadData：具体的数据加载逻辑updateItems：数据合并策略itemBuilder：列表项UI构建3. 状态管理class DialogState extends BaseViewState { List<TabData> tabDataList = [ LikeTabData(), CommentTabData(), LikeTabData(), CommentTabData(), ]; }4. 逻辑控制器class DialogLogic extends BaseGetXLifeCycleController{ late final PageController tabController; final state = DialogState(); @override void onInit() { super.onInit(); tabController = PageController(); state.viewState = TYPE_LOAD; update(); state.tabDataList.first.init().then((_)=> update()); Future.delayed(const Duration(seconds: 1),(){ state.tabDataList.first.isSelected = true; state.viewState = TYPE_NORMAL; update(); }); } @override void onClose() { tabController.dispose(); for(final item in state.tabDataList) { item.dispose(); } super.onClose(); } void onTabChange(int index) async{ for(int i = 0; i < state.tabDataList.length; i++) { state.tabDataList[i].isSelected = index == i; } update(); final isInited = await state.tabDataList[index].init(); if(!isInited) update(); } void changeTab(int index,BuildContext context){ final screenWidth = MediaQuery.of(context).size.width; tabController.animateTo(index * screenWidth,duration: const Duration(milliseconds: 300), curve: Curves.easeInOut); } void onRefresh(Future<void> Function() refresh) async{ await refresh(); update(); } void onLoadMore(Future<void> Function() loadMore) async{ await loadMore(); update(); } }5. UI界面实现class Dialog extends BaseTitleBottomSheet with GetXControllerMixin<DialogLogic>{ Dialog({super.key}); final logic = Get.put(DialogLogic()); final state = Get.find<DialogLogic>().state; @override String getTitle() => "Dialog"; @override Widget buildContent() { return buildPartBaseView( state.viewState,context, _buildContentWidget() ); } Widget _buildContentWidget() { return Expanded( child: Column( children: [ SingleChildScrollView( child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ for(int i = 0;i<state.tabDataList.length;i++) GestureDetector(onTap: ()=> logic.changeTab(i,context),child: _buildTabWidget(state.tabDataList[i])) ], ), ), Expanded( child: PageView.builder( controller: logic.tabController, onPageChanged: logic.onTabChange, itemCount: state.tabDataList.length, itemBuilder: (_,index){ final data = state.tabDataList[index]; if(data.items.isEmpty){ return const Center(child: Text("无数据"),); } return _KeepAliveList( data, keepAlive: true, onLoading: ()=> logic.onLoadMore(data.onLoadMore), onRefresh: ()=> logic.onRefresh(data.onRefresh), controller: data.controller ); }, ), ), ], ), ); } Widget _buildTabWidget(TabData entity){ final noSelectedStyle = TextStyle(color: Colors.black,fontSize: 23.w); final selectedStyle = TextStyle(color: Colors.blueAccent,fontSize: 23.w); final style = entity.isSelected ? selectedStyle : noSelectedStyle; return Row( mainAxisSize: MainAxisSize.min, children: [ Text(entity.name,style: style), Text("（${entity.total}）",style: style), ], ); } } class _KeepAliveList extends StatefulWidget { final TabData tabData; final bool keepAlive; final VoidCallback? onRefresh; final VoidCallback? onLoading; final RefreshController controller; const _KeepAliveList(this.tabData,{super.key,this.keepAlive = true,required this.controller,this.onRefresh,this.onLoading}); @override State<_KeepAliveList> createState() => _KeepAliveListState(); } class _KeepAliveListState extends State<_KeepAliveList> with AutomaticKeepAliveClientMixin{ @override Widget build(BuildContext context) { super.build(context); return SmartRefresher( controller: widget.controller, enablePullDown: true, enablePullUp: true, header: const ClassicHeader(), onRefresh: widget.onRefresh, onLoading: widget.onLoading, child: ListView.builder( itemCount: widget.tabData.items.length, itemBuilder: (context,index){ // 新版本dart可以使用switch匹配模式，可以穷举完全部密封类的子类 // 为了兼容只能使用if了 final tabData = widget.tabData; if(tabData is LikeTabData){ return tabData.itemBuilder(context,tabData.items[index]); }else if(tabData is CommentTabData){ return tabData.itemBuilder(context,tabData.items[index]); } throw "未知的TabData类型"; }, ), ); } @override bool get wantKeepAlive => widget.keepAlive; }设计优势1. 代码复用性公共的刷新、加载、分页逻辑在基类中实现新增Tab只需继承TabData并实现三个抽象方法2. 可维护性算法框架稳定，修改基类即可影响所有子类业务逻辑与UI展示分离3. 扩展性支持任意类型的列表数据可轻松添加新的Tab类型4. 一致性所有Tab保持相同的交互逻辑统一的错误处理和加载状态使用建议添加新Tab：只需创建新的TabData子类，实现loadData、updateItems和itemBuilder修改公共逻辑：在TabData基类中修改，所有子类自动生效自定义分页大小：在子类中重写pageSize getter特殊处理：子类可重写onRefresh或onLoadMore方法进行个性化处理总结通过模板方法模式，我们成功地将多Tab列表中的公共逻辑提取到基类中，同时保留了子类的灵活性。这种设计模式特别适用于具有相似流程但不同实现的场景，能够显著减少代码重复，提高项目的可维护性和扩展性。
我们通过 Dio 发起一个 ResponseType.stream 请求，消费后端逐字符返回的 HTTP 流。核心代码如下：void sendMessage() => Dio().get( "http://192.168.149.178:8080/stream/covert?url=${state.text}", options: Options(responseType: ResponseType.stream), cancelToken: CancelToken(), ).then((response) { final data = response.data; if (data is! ResponseBody) return; state.data = ""; update(); data.stream.listen((chunk) { final res = utf8.decoder.convert(chunk); state.data += res; update(); }); });UI 部分使用 Expanded + SingleChildScrollView 展示实时追加的文本：Column( children: [ TextField(controller: logic.text), TextButton(onPressed: logic.sendMessage, child: Text("发送消息")), Expanded( child: SelectionArea( child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [Text(state.data)], ), ), ), ), ], )后端使用 Spring WebFlux 将目标网页内容逐字符延迟返回：@GetMapping("/covert") public Flux<String> covert(@RequestParam String url) { return Mono.fromCallable(() -> { // 同步抓取网页内容（在非 Netty 线程执行） var request = new Request.Builder().url(url).build(); try (var resp = client.newCall(request).execute()) { return resp.body().string(); } }) .flux() .flatMap(text -> Flux.fromStream(text.chars().mapToObj(c -> String.valueOf((char) c))) .delayElements(Duration.ofMillis(10)) // 每字符间隔 10ms ); }该方案无需 SSE 或 WebSocket，仅依赖标准 HTTP 分块传输，即可实现“打字机式”流式响应，适用于 AI 对话、实时日志等场景。
引言在 Flutter 响应式布局体系中，LayoutBuilder 是一个强大而特殊的组件。与大多数在构建阶段（build phase）确定其子树的组件不同，LayoutBuilder 将子组件的构建推迟到布局阶段（layout phase），从而能够根据父级传递的实际布局约束动态构建 UI。本文将深入源码层面，全面剖析 LayoutBuilder 的工作原理。核心架构概览类继承层次Widget ├── RenderObjectWidget │ └── ConstrainedLayoutBuilder<BoxConstraints> │ └── LayoutBuilder (具体实现)主要角色分工ConstrainedLayoutBuilder<T> - 抽象基类，定义约束构建器的通用协议LayoutBuilder - 面向 BoxConstraints 的具体实现_LayoutBuilderElement - 连接 Widget 与 RenderObject 的桥梁_RenderLayoutBuilder - 实际执行布局的 RenderObject源码深度解析1. Element 的初始化与绑定// _LayoutBuilderElement.mount 方法 @override void mount(Element? parent, Object? newSlot) { super.mount(parent, newSlot); // 1. 创建对应的 RenderObject renderObject.updateCallback(_rebuildWithConstraints); // 2. 绑定重建回调 }关键点分析：super.mount() 会创建 _RenderLayoutBuilder 实例updateCallback 将 _rebuildWithConstraints 方法注册到 RenderObject此时回调并未立即执行，只是建立了调用关系2. 布局阶段的核心调用链// _RenderLayoutBuilder.performLayout 方法 @override void performLayout() { final BoxConstraints constraints = this.constraints; // 1. 获取父级约束 rebuildIfNecessary(); // 2. 触发约束重建 if (child != null) { child!.layout(constraints, parentUsesSize: true); // 3. 布局子节点 size = constraints.constrain(child!.size); // 4. 确定自身大小 } else { size = constraints.biggest; } }2.1 约束的来源追踪约束的传递遵循 Flutter 渲染管线的标准流程：// RenderObject 层级传递 void layout(Constraints constraints, { bool parentUsesSize = false }) { this.constraints = constraints; // 父组件传递约束 // ... 后续布局计算 }this.constraints 在 performLayout 中通过 this.constraints 访问，这是 RenderObject 的标准属性。2.2 重建触发机制// RenderConstrainedLayoutBuilder 混入类 void rebuildIfNecessary() { assert(_callback != null); invokeLayoutCallback(_callback!); // 调用注册的回调 } // RenderObject 中的回调执行 void invokeLayoutCallback<T extends Constraints>(LayoutCallback<T> callback) { owner!._enableMutationsToDirtySubtrees(() { callback(constraints as T); // 关键：传递当前约束 }); }3. 安全的回调执行环境_enableMutationsToDirtySubtrees 是保证布局阶段安全修改组件树的关键：void _enableMutationsToDirtySubtrees(VoidCallback callback) { assert(_debugDoingLayout); bool? oldState; assert(() { oldState = _debugAllowMutationsToDirtySubtrees; _debugAllowMutationsToDirtySubtrees = true; // 临时允许修改 return true; }()); try { callback(); // 执行 LayoutBuilder 的 builder } finally { _shouldMergeDirtyNodes = true; assert(() { _debugAllowMutationsToDirtySubtrees = oldState!; // 恢复原状态 return true; }()); } }安全机制解析：断言保护：仅在布局阶段允许此操作状态切换：临时开启脏子树修改权限异常安全：使用 try-finally 确保状态恢复操作合并：设置 _shouldMergeDirtyNodes 以优化更新4. 约束重建的实际执行void _rebuildWithConstraints(ConstraintType constraints) { // 构建子组件的闭包 void updateChildCallback() { Widget built = widget.builder(this, constraints); // 调用用户定义的builder _child = updateChild(_child, built, null); // 更新子Element _needsBuild = false; _previousConstraints = constraints; // 缓存约束 } // 条件性执行重建 final VoidCallback? callback = _needsBuild || (constraints != _previousConstraints) ? updateChildCallback : null; owner!.buildScope(this, callback); }智能优化机制：_previousConstraints 缓存上次的约束仅当约束变化或标记需要重建时才执行避免不必要的重建，提升性能5. 更新与重建调度5.1 组件更新处理@override void update(ConstrainedLayoutBuilder<ConstraintType> newWidget) { super.update(newWidget); renderObject.updateCallback(_rebuildWithConstraints); // 重新绑定 if (newWidget.updateShouldRebuild(oldWidget)) { _needsBuild = true; // 标记需要重建 renderObject.markNeedsLayout(); // 触发重新布局 } }5.2 智能调度策略void _scheduleRebuild() { // 根据调度阶段决定立即标记还是延迟 final bool deferMarkNeedsLayout = switch (SchedulerBinding.instance.schedulerPhase) { SchedulerPhase.idle || SchedulerPhase.postFrameCallbacks => true, _ => false, }; if (!deferMarkNeedsLayout) { renderObject.markNeedsLayout(); } else { // 在稳定阶段延迟调度 SchedulerBinding.instance.scheduleFrameCallback(_frameCallback); } }调度策略优势：立即响应：在布局/绘制阶段立即触发更新延迟优化：在空闲阶段延迟到下一帧，避免打断当前操作设计模式分析1. 延迟决策模式LayoutBuilder 实现了典型的延迟决策模式：构建阶段：只建立结构和回调关系布局阶段：根据实际约束做出具体构建决策优势：适应动态布局需求，支持响应式设计2. 回调注册模式通过 updateCallback 机制实现松耦合：Element 向 RenderObject 注册回调RenderObject 在适当时机触发回调分离了决策逻辑与执行时机3. 安全沙箱模式_enableMutationsToDirtySubtrees 提供安全执行环境：临时放宽框架限制执行用户代码后立即恢复平衡灵活性与安全性实战应用示例1. 经典响应式布局Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { // 根据宽度决定布局方式 if (constraints.maxWidth > 600) { return Row(children: [Sidebar(), ContentArea()]); } else { return Column(children: [AppBar(), ContentArea()]); } }, ); }2. 动态尺寸计算Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final itemWidth = (constraints.maxWidth - 32) / 3; return GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, childAspectRatio: itemWidth / 100, ), itemBuilder: (context, index) => ItemWidget(), ); }, ); }源码设计亮点1. 性能优化约束缓存：通过 _previousConstraints 避免重复构建条件更新：仅在实际变化时触发重建智能调度：根据框架状态选择最佳更新时机2. 错误边界void _rebuildWithConstraints(ConstraintType constraints) { try { built = widget.builder(this, constraints); } catch (e, stack) { built = ErrorWidget.builder(...); // 优雅降级 } // ... 错误处理继续 }3. 调试支持详细的断言检查调试信息收集异常报告机制总结与启示LayoutBuilder 的设计体现了 Flutter 框架的几个核心理念：关注点分离：将构建决策延迟到拥有完整布局信息时性能优先：通过多种优化避免不必要的计算安全第一：在灵活性与框架稳定性间取得平衡声明式表达：通过简单的回调 API 提供强大功能通过深入分析 LayoutBuilder 的源码，我们不仅理解了其工作原理，更能学习到如何设计既灵活又稳定的框架级组件。这种在布局阶段获取约束并动态构建的能力，为 Flutter 的响应式设计提供了坚实的基础设施支持。源码文件：packages/flutter/lib/src/widgets/layout_builder.dart 设计模式：延迟决策、回调注册、安全沙箱 适用场景：响应式布局、动态 UI、约束依赖型组件
这个教程算是在st7789屏幕没有买之前就已经开始打算了，之前的使用slint驱动oled也算是为这个打下基础吧创建新项目可以参考 ESP32C3使用Slint渲染OLED屏幕 引入slint依赖，创建跟配置slint跟这篇文章一样，引入的依赖基本上大差不差，只不过这个项目还需要引入st7789屏幕的相关依赖。然后在Cargo.toml中添加依赖 [dependencies] # 基础依赖 critical-section = "1.2.0" esp-hal = { version = "1.0.0-beta.0", features = ["esp32c3","unstable"] } esp-alloc = "0.7.0" esp-println = { version = "0.13.1",features = ["esp32c3","log"] } log = "0.4.27" esp-backtrace = { version = "0.15.1" ,features = ["esp32c3","println","panic-handler"]} # 图形相关 embedded-hal = "1.0.0" embedded-graphics-core = "0.4.0" embedded-graphics = "0.8.1" embedded-graphics-framebuf = "0.5.0" display-interface = "0.5.0" display-interface-spi = "0.5.0" mipidsi = "0.9.0" embedded-hal-bus = "0.3.0" # 异步 esp-hal-embassy = { version = "0.7.0", features = ["esp32c3"] } embassy-time = { version = "0.4.0", features = ["generic-queue-8"] } embassy-executor = { version = "0.7.0",features = ["task-arena-size-20480","executor-thread"] } #embassy-futures = "0.1.1" static_cell = { version = "2.1.0", features = ["nightly"] } tinygif = "0.0.4" [dependencies.slint] git = "https://github.com/slint-ui/slint" rev = "29168bc89270798f6075a0a729c14a3f011ceb4f" # 修复提交的哈希 default-features = false features = [ "compat-1-2", "unsafe-single-threaded", # 启用单线程模式 "libm", "renderer-software", ] [build-dependencies] slint-build = {git = "https://github.com/slint-ui/slint",rev = "29168bc89270798f6075a0a729c14a3f011ceb4f"} 创建ui文件创建 ui/main.slint 文件，作为程序的入口文件import { System,LocalTime,Route , Setting} from "./global.slint"; import { HomePage } from "./page/home_page.slint"; import { SettingPage } from "./page/setting_page.slint"; // 导出给rust代码调用 export { System,LocalTime,Setting } export component MainView inherits Window { background: black; width: System.screen_width; height: System.screen_height; default-font-family: "黑体"; // 简单实现路由 if System.route==Route.HomePage: HomePage{} if System.route==Route.SettingPage: SettingPage{} // 显示帧率 if Setting.fps == true:Rectangle { padding: 2px; x:0px; y:0px; width: fps.width; height: fps.height; background: black; fps:=Text { font-size: 20px; text: @tr("FPS:{}",System.fps); color: white; } } // 触摸切换页面，用于测试 TouchArea { clicked => { if(System.route == Route.HomePage){ System.route = Route.SettingPage; }else{ System.route = Route.HomePage; } } } }创建 ui/global.slint 存储全局变量，跟rust代码进行交互 export enum Route { HomePage, SettingPage } export global System { // 屏幕宽高 out property <length> screen_width:240px; out property <length> screen_height:240px; // 路由页面 in-out property <Route> route:Route.HomePage; // 帧率 in-out property <int> fps:0; } export global Setting { // 开启fps帧率显示 in-out property <bool> fps:true; } export global LocalTime { in-out property <int> hour:12; in-out property <int> minute:12; in-out property <int> scond:30; in-out property <int> year:2025; in-out property <int> month:12; in-out property <int> day:12; in-out property <int> week:1; private property <[string]> weeks:["周日","周一","周二","周三","周四","周五","周六"]; in-out property <string> week_cn:weeks[week]; }创建ui/page/home_page.slint文件展示主页import { System,LocalTime } from "../global.slint"; export component HomePage inherits Rectangle{ width: System.screen_width; height: System.screen_height; // 背景图片 bg:=Image { source: @image-url("../../assets/bg_ndmz.png"); } animate x { easing: ease-in-out; duration: 300ms; } VerticalLayout { spacing: 4px; alignment: start; week:=HorizontalLayout{ padding-top: 20px; alignment: center; spacing: 5px; Text { text: LocalTime.month; color: white; font-size: 20px; } Text { text: LocalTime.week_cn; color: white; font-size: 20px; } } time:=HorizontalLayout { padding: 2px; spacing: 5px; alignment: center; Rectangle { // background: blue; Text { text: LocalTime.hour; font-size: 60px; color: white; font-weight: 500; } } Text { width: 20px; text: ":"; font-size: 60px; color: white; font-weight: 500; } Rectangle { // background: pink; Text { text: LocalTime.minute; font-size: 60px; color: white; font-weight: 500; } } } } // 这里的定时器是简单模拟时间，实际项目应该从rust代码传入时间 Timer { running: true; interval: 1s; triggered => { LocalTime.minute+=1; } } }创建ui/page/setting_page.slint展示设置页面，这里简单做了给跑马灯的效果import { System } from "../global.slint"; export component SettingPage inherits Rectangle{ width: System.screen_width; height: System.screen_height; background: white; property <length> pos_x:0px; text:=Text { x:pos_x; text: "{}这里是设置页面aaaadfsadfsdf"; font-size: 40px; font-weight: 999; } Timer { running: true; interval: 50ms; triggered => { if(pos_x < System.screen_width - text.width){ pos_x = System.screen_width; }else{ pos_x -= 1px; } } } }编写main.rs代码#![no_std] #![no_main] extern crate alloc; use alloc::boxed::Box; use alloc::format; use alloc::rc::Rc; use alloc::string::ToString; use alloc::vec::Vec; use core::cell::RefCell; use core::fmt::Debug; use core::sync::atomic::{AtomicBool, Ordering}; use critical_section::Mutex; use embassy_executor::Spawner; use embassy_time::Timer; use embedded_graphics::mono_font::ascii::FONT_10X20; use embedded_graphics::mono_font::iso_8859_7::FONT_5X8; use embedded_graphics::mono_font::MonoTextStyle; use embedded_graphics::prelude::Primitive; use embedded_graphics::primitives::PrimitiveStyle; use embedded_graphics::text::Text; use embedded_graphics_core::Drawable; use embedded_graphics_core::pixelcolor::{Bgr565, BinaryColor, Rgb565}; use embedded_graphics_core::prelude::{DrawTarget, ImageDrawable, Point, RgbColor, Size}; use embedded_graphics_core::primitives::Rectangle; use embedded_hal::delay::DelayNs; use embedded_hal_bus::spi::{ExclusiveDevice, NoDelay}; use esp_hal::clock::{CpuClock, RadioClockController}; use esp_hal::delay::Delay; use esp_hal::{handler, main, ram, spi, Blocking}; use esp_hal::gpio::{Event, Input, InputConfig, Io, Level, Output, OutputConfig, Pull}; use esp_hal::i2c::master::I2c; use esp_hal::peripherals::Peripherals; use esp_hal::rtc_cntl::Rtc; use esp_hal::spi::master::Config; use esp_hal::spi::master::Spi; use esp_hal::spi::Mode; use esp_hal::time::{Duration, Instant, Rate}; use esp_hal::timer::systimer::SystemTimer; use esp_hal::timer::timg::TimerGroup; use esp_println::logger::{init_logger, init_logger_from_env}; use esp_println::println; use log::{error, info, warn, LevelFilter}; use mipidsi::{Builder, Display}; use mipidsi::interface::SpiInterface; use mipidsi::models::ST7789; use mipidsi::options::{ColorInversion, Orientation}; use slint::platform::{Platform, WindowAdapter}; use slint::platform::software_renderer::{LineBufferProvider, MinimalSoftwareWindow, Rgb565Pixel}; use slint::{invoke_from_event_loop, ModelRc, PhysicalSize, PlatformError, Rgb8Pixel, Rgba8Pixel, SharedPixelBuffer, TimerMode, Weak}; slint::include_modules!(); #[panic_handler] fn panic(info: &core::panic::PanicInfo) -> ! { error!("panic:{}",info); loop {} } #[esp_hal_embassy::main] async fn main(spawner: Spawner) { // generator version: 0.3.1 // init_logger_from_env(); init_logger(LevelFilter::Debug); esp_alloc::heap_allocator!(size: 100 * 1024); info!("分配内存完成"); let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); let peripherals =esp_hal::init(config); // 配置异步 let timer0 = SystemTimer::new(peripherals.SYSTIMER); esp_hal_embassy::init(timer0.alarm0); let mut delay = Delay::new(); let mut rst = Output::new(peripherals.GPIO3,Level::Low,OutputConfig::default()); rst.set_high(); let dc = Output::new(peripherals.GPIO4,Level::Low,OutputConfig::default()); let mut backlight = Output::new(peripherals.GPIO5,Level::Low,OutputConfig::default()); let sclk = peripherals.GPIO6; let sda = peripherals.GPIO7; let sdi = peripherals.GPIO8; let cs = peripherals.GPIO10; let spi = Spi::new(peripherals.SPI2, Config::default() .with_mode(Mode::_3) .with_frequency(Rate::from_mhz(60)) ).unwrap() .with_sck(sclk) .with_miso(sdi) .with_mosi(sda); let cs_output = Output::new(cs,Level::High,OutputConfig::default()); let spi_device = ExclusiveDevice::new_no_delay(spi,cs_output).unwrap(); let di = SpiInterface::new(spi_device,dc,unsafe{ &mut BUFFER }); let display = Builder::new(ST7789, di) .display_size(240,240) .invert_colors(ColorInversion::Inverted) .color_order(mipidsi::options::ColorOrder::Rgb) .orientation(Orientation::new()) .reset_pin(rst) .init(&mut delay).unwrap(); println!("display 初始化完成"); backlight.set_high(); info!("开启异步任务"); spawner.spawn(ui_run(display)).ok(); // for inspiration have a look at the examples at https://github.com/esp-rs/esp-hal/tree/esp-hal-v1.0.0-beta.0/examples/src/bin } static mut BUFFER: [u8; 512] = [0_u8; 512]; type DISPLAY = Display<SpiInterface<'static, ExclusiveDevice<Spi<'static,Blocking>,Output<'static>,NoDelay>,Output<'static>>,ST7789,Output<'static>>; #[embassy_executor::task] async fn ui_run(mut display: DISPLAY){ // 创建平台实例 let window = MinimalSoftwareWindow::new( slint::platform::software_renderer::RepaintBufferType::ReusedBuffer, ); window.set_size(PhysicalSize::new(240,240)); let platform = EspPlatform::new(window.clone()); slint::platform::set_platform(Box::new(platform)).unwrap(); // 创建ui let ui = MainView::new().unwrap(); ui.window().set_size(PhysicalSize::new(240,240)); // 初始化显示 display.clear(Rgb565::BLACK).unwrap(); info!("清理屏幕"); // 创建行缓冲区 let display_width = 240; // 根据实际显示宽度设置 let mut the_frame_buffer = [Rgb565Pixel(0); 240 ]; let mut time = Instant::now();; let mut last_frame_time = time.duration_since_epoch(); let mut fps = 0; let mut frame_count = 0; loop { frame_count+=1; // 更新UI状态 slint::platform::update_timers_and_animations(); // 渲染UI window.draw_if_needed(|renderer| { renderer.render_by_line(FrameBuffer{ frame_buffer: &mut the_frame_buffer, stride: display_width,display:&mut display }); }); // 计算FPS let now = time.duration_since_epoch(); if now.as_millis()-last_frame_time.as_millis() >= 1000{ last_frame_time = now; fps = frame_count; frame_count = 0; ui.global::<System>().set_fps(fps); } // 根据动画状态决定是否延迟 if !window.has_active_animations() { if let Some(duration) = slint::platform::duration_until_next_timer_update(){ Timer::after_millis(duration.as_millis() as u64).await; continue; } } Timer::after_millis(10).await; } } // 平台实现结构体 struct EspPlatform{ window: Rc<MinimalSoftwareWindow>, } impl EspPlatform { fn new(window:Rc<MinimalSoftwareWindow>) -> Self { Self { window } } } /// 实现 Platform trait impl Platform for EspPlatform { fn create_window_adapter(&self) -> Result<Rc<dyn WindowAdapter>, PlatformError> { info!("成功创建了window"); Ok(self.window.clone()) } fn duration_since_start(&self) -> core::time::Duration { // 使用定时器获取时间（需要实际硬件计时器实现） let time = Instant::now().duration_since_epoch().as_millis(); // info!("time :{}",time); core::time::Duration::from_millis( time ) } } struct FrameBuffer<'a>{ frame_buffer: &'a mut [Rgb565Pixel], stride: usize,display:&'a mut DISPLAY } impl<'a> LineBufferProvider for FrameBuffer<'a> { type TargetPixel = Rgb565Pixel; fn process_line( &mut self, line: usize, range: core::ops::Range<usize>, render_fn: impl FnOnce(&mut [Self::TargetPixel]), ) { let buf = &mut self.frame_buffer[range.clone()]; render_fn(buf); self.display.set_pixels( range.start as u16, line as _, range.end as u16, line as u16, buf.iter().map(|x|{ embedded_graphics_core::pixelcolor::raw::RawU16::new(x.0).into() }) ).unwrap() } } 效果展示浏览首页页面设置页面最终的效果展示
因为使用的是新版esp-generate创建的项目，很多api的使用都大改了，网上能搜到的类似的教程也都完全没法用，这个教程是使用最新版esp-hal基于rust开发esp32c3驱动st7789屏幕。看网上有些项目叫MOSI，MISO的，也有叫SDI，SDO，这两个本质就是一个东西，在不同协议叫法不同。SPI 协议中大家都喜欢叫 MOSI / MISO，有些芯片厂商文档上写的是 SDI / SDO；MOSI == SDI（对于从机）：表示“数据输入”MISO == SDO（对于从机）：表示“数据输出”项目创建在 rust裸机开发esp32c3 的基础上，先新建一个最基础的环境引入依赖[dependencies] critical-section = "1.2.0" esp-hal = { version = "1.0.0-beta.0", features = ["esp32c3","unstable"] } esp-println = { version = "0.13.1",features = ["esp32c3","log"] } log = "0.4.27" # 图形相关 embedded-hal = "1.0.0" embedded-graphics-core = "0.4.0" embedded-graphics = "0.8.1" embedded-graphics-framebuf = "0.5.0" display-interface = "0.5.0" display-interface-spi = "0.5.0" mipidsi = "0.9.0" embedded-hal-bus = "0.3.0" 编写main.rs代码#![no_std] #![no_main] use embedded_graphics::Drawable; use embedded_graphics::image::{Image, ImageRawLE}; use embedded_graphics::pixelcolor::Rgb565; use embedded_graphics::prelude::{DrawTarget, Point, Primitive, RgbColor}; use embedded_graphics::primitives::PrimitiveStyle; use embedded_graphics_core::prelude::Size; use embedded_graphics_core::primitives::Rectangle; use embedded_hal::delay::DelayNs; use embedded_hal::digital::ErrorType; use embedded_hal_bus::spi::ExclusiveDevice; use esp_hal::clock::CpuClock; use esp_hal::delay::Delay; use esp_hal::gpio::{Level, Output, OutputConfig}; use esp_hal::main; use esp_hal::spi::master::{Config, Spi}; use esp_hal::spi::Mode; use esp_hal::time::{Duration, Instant, Rate}; use esp_println::logger::init_logger; use esp_println::println; use log::{error, LevelFilter}; use mipidsi::_mock::MockSpi; use mipidsi::models::ST7789; use mipidsi::Builder; use mipidsi::interface::SpiInterface; use mipidsi::options::Orientation; #[panic_handler] fn panic(e: &core::panic::PanicInfo) -> ! { println!("{}",e); loop { } } #[main] fn main() -> ! { // generator version: 0.3.1 let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); init_logger(LevelFilter::Debug); let peripherals = esp_hal::init(config); let mut delay = Delay::new(); let mut rst = Output::new(peripherals.GPIO3,Level::Low,OutputConfig::default()); delay.delay_ms(20); // 保持 RST 为低电平至少 10~20ms rst.set_high(); // 然后再拉高 delay.delay_ms(100); // 拉高后也延迟一下，再继续初始化 let dc = Output::new(peripherals.GPIO4,Level::Low,OutputConfig::default()); let mut backlight = Output::new(peripherals.GPIO5,Level::Low,OutputConfig::default()); let sclk = peripherals.GPIO6; let sda = peripherals.GPIO7; let sdi = peripherals.GPIO8; let cs = peripherals.GPIO10; let spi = Spi::new(peripherals.SPI2, Config::default() .with_mode(Mode::_3) .with_frequency(Rate::from_mhz(40)) ).unwrap() .with_sck(sclk) .with_miso(sdi) .with_mosi(sda); let cs_output = Output::new(cs,Level::High,OutputConfig::default()); let spi_device = ExclusiveDevice::new_no_delay(spi,cs_output).unwrap(); let mut buffer = [0_u8; 512]; let di = SpiInterface::new(spi_device,dc,&mut buffer); let mut display = Builder::new(ST7789,di) .display_size(240,240) .orientation(Orientation::new()) .reset_pin(rst) .init(&mut delay).unwrap(); println!("display 初始化完成"); backlight.set_high(); let raw_image_data = ImageRawLE::new(include_bytes!("../../assets/ferris.raw"), 86); let mut x = 0; loop { display.clear(Rgb565::BLACK).unwrap(); let ferris = Image::new(&raw_image_data, Point::new(x, 0)); // draw image on black background display.clear(Rgb565::BLACK).unwrap(); ferris.draw(&mut display).unwrap(); delay.delay_millis(400); x+=10; if x >= 140{ x = 0; } } // for inspiration have a look at the examples at https://github.com/esp-rs/esp-hal/tree/esp-hal-v1.0.0-beta.0/examples/src/bin } 效果展示{callout color="#efb44d"}屏幕是倒着放到，图片显示是正的{/callout}{lamp/}本项目参考了mipidsi(esp32c3) esp-idf-hal/examples /spi_st7789.rs
前言：说一下为什么要用slint，既然都选择用rust开发嵌入式了避免不了要折腾，也是为后续驱动st7789做铺垫吧。而其实对应OLED屏幕已经有一个很好用的库了embedded-graphics，几乎能满足OLED屏幕的全部需求了，但是slint有动画，定时器，声明式，这些优势，再嵌入式开发再合适不过了。在写这个的时候也遇到不少坑，所以记录一下吧创建项目引入库编译问题已经在 ESP32C3引入Slint 文章解决了先创建一个最基础的esp32c3的项目然后再Cargo.toml中添加依赖# 基础依赖 critical-section = "1.2.0" esp-hal = { version = "1.0.0-beta.0", features = ["esp32c3","unstable"] } esp-alloc = "0.7.0" esp-println = { version = "0.13.1",features = ["esp32c3","log"] } log = "0.4.27" esp-backtrace = { version = "0.15.1" ,features = ["esp32c3","println","panic-handler"]} # 图形相关 embedded-hal = "0.2.7" embedded-graphics-core = "0.4.0" embedded-graphics = "0.8.1" embedded-graphics-framebuf = "0.5.0" ssd1306 = { version = "0.10.0" ,features = ["graphics"]} # 异步 esp-hal-embassy = { version = "0.7.0", features = ["esp32c3"] } embassy-time = { version = "0.4.0", features = ["generic-queue-8"] } embassy-executor = { version = "0.7.0",features = ["task-arena-size-20480"] } static_cell = { version = "2.1.0", features = ["nightly"] } [dependencies.slint] git = "https://github.com/slint-ui/slint" rev = "29168bc89270798f6075a0a729c14a3f011ceb4f" # 修复提交的哈希 default-features = false features = [ "compat-1-2", "unsafe-single-threaded", # 启用单线程模式 "libm", "renderer-software", ] [build-dependencies] slint-build = {git = "https://github.com/slint-ui/slint",rev = "29168bc89270798f6075a0a729c14a3f011ceb4f"} 配置Slint我们使用slint的ui跟代码分离的模式，修改build.rs新增一个build_slint函数，然后在main函数中调用它 fn main(){ build_slint(); ... } fn build_slint(){ slint_build::compile_with_config( "ui/MainView.slint", slint_build::CompilerConfiguration::new() .embed_resources(slint_build::EmbedResourcesKind::EmbedForSoftwareRenderer) ).unwrap() }创建UI文件在项目根目录下创建 ui/MainView.slint 文件，定义 UI 界面// 由于是在no_std的环境下，没有默认字体，使用需要引入自定义的字体 import "./fonts/simhei.ttf"; // 这里定义了一个全局的状态，用于在rust代码里进行修改ui状态 export global MyState { in-out property <bool> is_show : true; } export component MainView inherits Window { default-font-family: "黑体"; width: 128px; height: 64px; Rectangle { x:0px; y:0px; width: 100%; height: 100%; background: transparent; border-color: black; border-width: 5px; } Rectangle { x:10px; y:6px; width: 50px; height: 50px; background: black; border-radius: 360px; visible: MyState.is_show; } property <bool> is_show : false; Rectangle { x:80px; y:6px; width: 20px; height: 20px; background: black; visible: is_show; } // 使用了slint的ui的定时器 timer:=Timer{ interval: 0.5s; running: true; triggered => { is_show = !is_show; } } Text { font-family: "SimHei"; text: "12345"; color: black; font-weight: 900; font-size: 40px; x:66px; } }编写main.rs代码因为是使用的no_std，没有多线程，而slint的run方法会调用invoke_from_event_loop方法，进而导致阻塞线程，所以需要改造，不使用run方法来渲染，而是手动处理渲染，在实现traitPlatform时，不去重写invoke_from_event_loop函数，我们在初始化代码块去使用异步发方式实现 slint::include_modules!(); #[panic_handler] fn panic(info: &core::panic::PanicInfo) -> ! { loop { error!("panic:{}",info); } } #[esp_hal_embassy::main] async fn main(spawner: Spawner) -> ! { // generator version: 0.3.1 // init_logger_from_env(); init_logger(LevelFilter::Debug); esp_alloc::heap_allocator!(size: 100 * 1024); info!("分配内存完成"); let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); let peripherals =esp_hal::init(config); // 配置异步 let timer0 = SystemTimer::new(peripherals.SYSTIMER); esp_hal_embassy::init(timer0.alarm0); let i2c = esp_hal::i2c::master::I2c::new(peripherals.I2C0, esp_hal::i2c::master::Config::default() .with_frequency(Rate::from_khz(400))) .unwrap() .with_sda(peripherals.GPIO8) .with_scl(peripherals.GPIO9); let display = Ssd1306::new( I2CDisplayInterface::new(i2c), DisplaySize128x64, DisplayRotation::Rotate0).into_buffered_graphics_mode(); info!("开启异步任务"); spawner.spawn(ui_run(display,spawner)).ok(); loop{ Timer::after_secs(2).await; } // for inspiration have a look at the examples at https://gitcahub.com/esp-rs/esp-hal/tree/esp-hal-v1.0.0-beta.0/examples/src/bin } #[embassy_executor::task] async fn ui_run(mut display: SSD,spawner: Spawner){ // 创建平台实例 let window = slint::platform::software_renderer::MinimalSoftwareWindow::new( slint::platform::software_renderer::RepaintBufferType::ReusedBuffer, ); window.set_size(PhysicalSize::new(128,64)); let platform = EspPlatform::new(window.clone()); slint::platform::set_platform(Box::new(platform)).unwrap(); // 创建ui let ui = MainView::new().unwrap(); ui.window().set_size(PhysicalSize::new(128,64)); let ui_weak = ui.as_weak(); spawner.spawn(ui_task(ui_weak)).ok(); info!("进入了loop run"); // 初始化显示 display.init().unwrap(); display.clear(BinaryColor::Off).unwrap(); display.flush().unwrap(); info!("清理屏幕 on"); // 创建行缓冲区 let display_width = 128; // 根据实际显示宽度设置 let mut the_frame_buffer = [Rgb565Pixel(0); 128 * 64]; // 128x64 // 这里是我们手动实现的invoke_from_event_loop loop { // 更新UI状态 slint::platform::update_timers_and_animations(); // 渲染UI window.draw_if_needed(|renderer| { info!("进入渲染回调"); renderer.render_by_line(FrameBuffer{ frame_buffer: &mut the_frame_buffer, stride: display_width,display:&mut display }); info!("退出渲染回调"); // 退出渲染回调，说明整个帧已经渲染完成，这时候刷新屏幕 display.flush().unwrap(); }); Timer::after_millis(100).await; } } #[embassy_executor::task] async fn ui_task(view:Weak<MainView>){ info!("ui_task 已经开启"); loop{ // 在这里用rust的代码更新slint的ui状态 let view = view.clone(); let view = view.upgrade().unwrap(); let show = view.global::<MyState>().get_is_show(); view.global::<MyState>().set_is_show(!show); info!("show的状态为：{}",show); Timer::after_secs(2).await; } } // 平台实现结构体 // 为了方便，先将OLED的屏幕生命周期设置为静态的 type SSD = Ssd1306<I2CInterface<I2c<'static,Blocking>>,DisplaySize128x64,BufferedGraphicsMode<DisplaySize128x64>>; struct EspPlatform{ window: Rc<slint::platform::software_renderer::MinimalSoftwareWindow>, } impl EspPlatform { fn new(window:Rc<MinimalSoftwareWindow>) -> Self { Self { window } } } /// 实现 Platform trait impl Platform for EspPlatform { fn create_window_adapter(&self) -> Result<Rc<dyn WindowAdapter>, PlatformError> { info!("成功创建了window"); Ok(self.window.clone()) } fn duration_since_start(&self) -> core::time::Duration { let time = Instant::now().duration_since_epoch().as_millis(); // info!("time :{}",time); core::time::Duration::from_millis( time ) } } struct FrameBuffer<'a>{ frame_buffer: &'a mut [Rgb565Pixel], stride: usize,display:&'a mut SSD } impl<'a> LineBufferProvider for FrameBuffer<'a> { type TargetPixel = Rgb565Pixel; fn process_line( &mut self, line: usize, range: core::ops::Range<usize>, render_fn: impl FnOnce(&mut [Self::TargetPixel]), ) { let line_start = line * self.stride; let line_pixels = &mut self.frame_buffer[line_start..line_start + 128]; render_fn(&mut line_pixels[range.clone()]); // 规定黑色为亮，由于是在oled屏幕上渲染，只有亮或者不亮，为了方便我们规定一个颜色为亮色，其余的颜色都视为不亮，在ui页面也是，使用黑色作为渲染的区域 let black = Rgb565Pixel(0); for (x,row) in line_pixels.iter().enumerate(){ // 先将要更新的行的地方进行清屏 self.display.set_pixel(x as u32,line as u32,false); // 然后对应要渲染的像素点进行渲染 if row == &black{ self.display.set_pixel(x as u32, line as u32, true); } } } } 编译查看效果预览页面烧录的效果跟我们slint编写的ui页面一致