首页
苏兮影视
随笔记
壁纸
更多
直播
时光轴
友联
关于
统计
Search
1
v2ray节点搭建
1,074 阅读
2
软件添加id功能按钮
1,029 阅读
3
QQ扫码无法登录的解决方案
990 阅读
4
网易云音乐歌单ID获取教程
921 阅读
5
js逆向xhs
792 阅读
谈天说地
建站源码
经验教程
资源分享
动漫美图
登录
Search
标签搜索
java
rust
flutter
esp32c3
springboot
安卓
linux
vue
dart
设计模式
docker
ssh
joe
快捷键
git
fish shell
maven
redis
netty
groovy
尽意
累计撰写
116
篇文章
累计收到
41
条评论
首页
栏目
谈天说地
建站源码
经验教程
资源分享
动漫美图
页面
苏兮影视
随笔记
壁纸
直播
时光轴
友联
关于
统计
搜索到
116
篇与
的结果
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... 设备,说明无线网卡的驱动程序没装好,这时候配置网络是没用的,需要先解决驱动问题。
2026年07月26日
5 阅读
0 评论
0 点赞
刷 Linux 前置操作配置指南:MBR + 传统模式篇
在折腾各类“纯血”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 引导镜像,即可开始你的开源之旅。
2026年07月26日
11 阅读
0 评论
0 点赞
使用 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 环境下的远程访问,还保证了连接的极高稳定性与安全性。这套方案配置一次,即可长期稳定运行,是家庭实验室和远程运维的绝佳选择。
2026年07月26日
4 阅读
0 评论
0 点赞
2026-06-18
记录几个flutter自定义组件
波浪效果 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; } } 抛物线动画效果
2026年06月18日
15 阅读
0 评论
0 点赞
Flutter模板方法模式在多Tab列表中的应用
项目背景与需求在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列表中的公共逻辑提取到基类中,同时保留了子类的灵活性。这种设计模式特别适用于具有相似流程但不同实现的场景,能够显著减少代码重复,提高项目的可维护性和扩展性。
2026年01月16日
38 阅读
0 评论
2 点赞
1
2
...
24