Flutter+OpenHarmony实战level_tool水平仪
前言
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
level_tool 是一个基于 Flutter 实现的 Level Tool 水平仪 UI Demo 。项目核心源码位于 lib/main.dart,同时保留了 ohos OpenHarmony 平台工程目录,适合用来学习 Flutter CustomPainter 绘图 、pitch/roll 姿态数据模拟 、气泡位置映射 、水平状态判断 和 OpenHarmony 平台承载。
需要先说明:当前项目没有接入真实加速度计、陀螺仪或姿态传感器,而是通过 _simulateLevel() 模拟 pitch 和 roll 的变化。因此本文会严格基于真实源码,把它定位为 水平仪 UI 与传感器能力扩展案例,既讲清楚当前 Demo 的绘制逻辑,也说明后续接入 OpenHarmony 真机传感器时需要关注的能力边界。
本文重点回答三个问题:
level_tool如何用_pitch、_roll和_isLevel表达水平仪状态。- Flutter 如何使用
CustomPainter、Canvas、Paint、RRect绘制圆形与横向水平仪。 - 这个项目适配 OpenHarmony 时,如何区分 UI 模拟能力和真实传感器能力。

图示说明:本文聚焦 Flutter 水平仪 UI Demo 的姿态模拟、气泡映射和自定义绘制,核心源码位于 lib/main.dart。
一、背景与目标
1.1 项目功能概览
level_tool 当前是一个水平仪视觉模拟项目。页面上方显示 LEVEL! 或 Not Level 状态,中间绘制圆形气泡水平仪,下方展示 Pitch 和 Roll 数值,底部还有一个横向水平仪条。
当前项目真实支持的功能包括:
- 展示
LEVEL!或Not Level状态文本。 - 模拟 pitch 俯仰角。
- 模拟 roll 横滚角。
- 根据 pitch 和 roll 判断是否水平。
- 根据倾斜程度切换气泡颜色。
- 使用
CustomPaint绘制圆形气泡水平仪。 - 绘制圆形外框、十字准线和目标圆。
- 根据 pitch/roll 计算圆形水平仪气泡位置。
- 使用
CustomPaint绘制底部横向水平仪。 - 使用
clamp限制横向气泡偏移范围。 - 每 100ms 自动刷新 UI。
- 使用
mounted保护异步循环中的setState。
1.2 技术关键词
| 关键词 | 在项目中的作用 |
|---|---|
pitch |
俯仰角,影响圆形气泡纵向位置 |
roll |
横滚角,影响圆形气泡横向位置和横向水平仪气泡 |
_isLevel |
判断是否接近水平 |
Future.doWhile |
模拟姿态数据持续变化 |
Future.delayed |
控制刷新间隔为 100ms |
mounted |
保护异步刷新中的 setState |
CustomPainter |
封装水平仪绘制逻辑 |
Canvas |
绘制圆、线、圆角矩形和气泡 |
RRect |
绘制横向水平仪背景 |
clamp |
限制横向气泡偏移范围 |
1.3 Demo 能力边界
| 能力 | 当前是否实现 | 说明 |
|---|---|---|
| 圆形水平仪 UI | 是 | BubbleLevelPainter 绘制 |
| 横向水平仪 UI | 是 | HorizontalLevelPainter 绘制 |
| pitch/roll 状态 | 是 | 使用模拟数据 |
| 水平状态判断 | 是 | _pitch.abs() < 1 && _roll.abs() < 1 |
| 气泡颜色规则 | 是 | 绿色、黄色、红色三段 |
| 真实传感器读取 | 否 | 当前未接入硬件 |
| OpenHarmony 传感器权限 | 否 | 当前不需要权限声明 |
关键点:当前项目是水平仪 UI Demo,不是真实硬件水平仪。它的价值在于演示姿态数据如何映射成气泡位置、颜色和状态文案。
二、环境准备
2.1 项目目录结构
当前项目采用 Flutter 标准目录,同时包含 OpenHarmony 平台工程。
bash
level_tool/
├── lib/
│ └── main.dart
├── ohos/
│ ├── AppScope/
│ └── entry/
├── test/
│ └── widget_test.dart
├── pubspec.yaml
├── analysis_options.yaml
└── README.md
2.2 核心文件说明
| 文件或目录 | 作用 | 本文重点 |
|---|---|---|
lib/main.dart |
应用入口、状态模拟、颜色规则和水平仪绘制 | 核心源码 |
pubspec.yaml |
项目名称、版本、依赖和 Flutter 配置 | 环境依赖 |
analysis_options.yaml |
Dart 静态分析规则 | 代码规范 |
test/ |
Flutter 测试目录 | 水平判断和页面验证 |
ohos/ |
OpenHarmony 平台工程 | 平台承载 |
2.3 pubspec 基础配置
项目名称是 level_tool,版本是 1.0.0+1,Dart SDK 约束是 ^3.9.2。
yaml
name: level_tool
description: "A new Flutter project."
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.9.2
publish_to: 'none' 表示这是应用工程,不会被误发布到 pub.dev。
2.4 项目依赖
当前项目只使用 Flutter SDK 和基础图标库。
yaml
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
源码中引入了 dart:math,它属于 Dart 标准库,不需要额外声明依赖。
2.5 常用命令
bash
flutter pub get
flutter analyze
flutter test
flutter run
| 命令 | 作用 |
|---|---|
flutter pub get |
获取项目依赖 |
flutter analyze |
执行静态分析 |
flutter test |
运行测试 |
flutter run |
启动调试运行 |
flutter build hap |
构建 OpenHarmony HAP 包 |
三、应用入口与主题配置
3.1 import 语句
当前源码引入了 Flutter Material 组件和 Dart 数学库。
dart
import 'package:flutter/material.dart';
import 'dart:math' as math;
dart:math 在当前源码中被引入为 math。虽然当前绘制逻辑主要通过线性映射计算气泡位置,但这个库为后续扩展角度、旋转或更复杂几何计算预留了基础。
3.2 main 函数
dart
void main() {
runApp(const LevelToolApp());
}
runApp(const LevelToolApp()) 将 LevelToolApp 放入 Flutter 渲染树根节点。
3.3 LevelToolApp
dart
class LevelToolApp extends StatelessWidget {
const LevelToolApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Level Tool',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
useMaterial3: true,
),
home: const LevelToolHomePage(title: 'Level Tool'),
);
}
}
这里有三个关键信息:
- 应用标题是
Level Tool。 - 主题种子色是
Colors.teal,符合工具类应用清爽、稳定的视觉气质。 - 首页是
LevelToolHomePage,水平仪状态从首页 State 中维护。
四、页面状态设计
4.1 LevelToolHomePage
dart
class LevelToolHomePage extends StatefulWidget {
const LevelToolHomePage({super.key, required this.title});
final String title;
@override
State<LevelToolHomePage> createState() => _LevelToolHomePageState();
}
水平仪的 pitch、roll 和水平状态会持续变化,因此页面使用 StatefulWidget。
4.2 核心状态字段
dart
class _LevelToolHomePageState extends State<LevelToolHomePage> {
double _pitch = 0;
double _roll = 0;
bool _isLevel = false;
}
| 字段 | 类型 | 作用 |
|---|---|---|
_pitch |
double |
俯仰角,影响气泡上下位置 |
_roll |
double |
横滚角,影响气泡左右位置 |
_isLevel |
bool |
判断当前是否接近水平 |
4.3 状态到 UI 的关系
#mermaid-svg-83tXbgcYjEwTyAR4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-83tXbgcYjEwTyAR4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-83tXbgcYjEwTyAR4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-83tXbgcYjEwTyAR4 .error-icon{fill:#552222;}#mermaid-svg-83tXbgcYjEwTyAR4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-83tXbgcYjEwTyAR4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-83tXbgcYjEwTyAR4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-83tXbgcYjEwTyAR4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-83tXbgcYjEwTyAR4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-83tXbgcYjEwTyAR4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-83tXbgcYjEwTyAR4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-83tXbgcYjEwTyAR4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-83tXbgcYjEwTyAR4 .marker.cross{stroke:#333333;}#mermaid-svg-83tXbgcYjEwTyAR4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-83tXbgcYjEwTyAR4 p{margin:0;}#mermaid-svg-83tXbgcYjEwTyAR4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-83tXbgcYjEwTyAR4 .cluster-label text{fill:#333;}#mermaid-svg-83tXbgcYjEwTyAR4 .cluster-label span{color:#333;}#mermaid-svg-83tXbgcYjEwTyAR4 .cluster-label span p{background-color:transparent;}#mermaid-svg-83tXbgcYjEwTyAR4 .label text,#mermaid-svg-83tXbgcYjEwTyAR4 span{fill:#333;color:#333;}#mermaid-svg-83tXbgcYjEwTyAR4 .node rect,#mermaid-svg-83tXbgcYjEwTyAR4 .node circle,#mermaid-svg-83tXbgcYjEwTyAR4 .node ellipse,#mermaid-svg-83tXbgcYjEwTyAR4 .node polygon,#mermaid-svg-83tXbgcYjEwTyAR4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-83tXbgcYjEwTyAR4 .rough-node .label text,#mermaid-svg-83tXbgcYjEwTyAR4 .node .label text,#mermaid-svg-83tXbgcYjEwTyAR4 .image-shape .label,#mermaid-svg-83tXbgcYjEwTyAR4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-83tXbgcYjEwTyAR4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-83tXbgcYjEwTyAR4 .rough-node .label,#mermaid-svg-83tXbgcYjEwTyAR4 .node .label,#mermaid-svg-83tXbgcYjEwTyAR4 .image-shape .label,#mermaid-svg-83tXbgcYjEwTyAR4 .icon-shape .label{text-align:center;}#mermaid-svg-83tXbgcYjEwTyAR4 .node.clickable{cursor:pointer;}#mermaid-svg-83tXbgcYjEwTyAR4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-83tXbgcYjEwTyAR4 .arrowheadPath{fill:#333333;}#mermaid-svg-83tXbgcYjEwTyAR4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-83tXbgcYjEwTyAR4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-83tXbgcYjEwTyAR4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-83tXbgcYjEwTyAR4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-83tXbgcYjEwTyAR4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-83tXbgcYjEwTyAR4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-83tXbgcYjEwTyAR4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-83tXbgcYjEwTyAR4 .cluster text{fill:#333;}#mermaid-svg-83tXbgcYjEwTyAR4 .cluster span{color:#333;}#mermaid-svg-83tXbgcYjEwTyAR4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-83tXbgcYjEwTyAR4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-83tXbgcYjEwTyAR4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-83tXbgcYjEwTyAR4 .icon-shape,#mermaid-svg-83tXbgcYjEwTyAR4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-83tXbgcYjEwTyAR4 .icon-shape p,#mermaid-svg-83tXbgcYjEwTyAR4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-83tXbgcYjEwTyAR4 .icon-shape .label rect,#mermaid-svg-83tXbgcYjEwTyAR4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-83tXbgcYjEwTyAR4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-83tXbgcYjEwTyAR4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-83tXbgcYjEwTyAR4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} _pitch
Pitch 文本
_roll
Roll 文本
圆形气泡 Y 坐标
圆形气泡 X 坐标
横向水平仪气泡 X 坐标
_isLevel
LEVEL / Not Level 文案
气泡颜色
_pitch 和 _roll 是页面的核心数据,几乎所有视觉反馈都由这两个数值派生。
五、姿态模拟逻辑
5.1 initState 启动模拟
dart
@override
void initState() {
super.initState();
_simulateLevel();
}
页面初始化后启动 _simulateLevel(),让水平仪开始持续变化。
5.2 _simulateLevel 方法
dart
void _simulateLevel() {
Future.doWhile(() async {
await Future.delayed(const Duration(milliseconds: 100));
if (mounted) {
setState(() {
_pitch = (_pitch + 0.3) % 10 - 5;
_roll = (_roll + 0.2) % 8 - 4;
_isLevel = _pitch.abs() < 1 && _roll.abs() < 1;
});
return true;
}
return false;
});
}
这段代码每 100ms 更新一次模拟姿态数据。
5.3 模拟范围
| 字段 | 更新公式 | 大致范围 |
|---|---|---|
_pitch |
(_pitch + 0.3) % 10 - 5 |
-5 到 5 附近循环 |
_roll |
(_roll + 0.2) % 8 - 4 |
-4 到 4 附近循环 |
_isLevel |
_pitch.abs() < 1 && _roll.abs() < 1 |
接近水平时为 true |
5.4 刷新流程
#mermaid-svg-hP4hZibmDSUbyIBI{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hP4hZibmDSUbyIBI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hP4hZibmDSUbyIBI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hP4hZibmDSUbyIBI .error-icon{fill:#552222;}#mermaid-svg-hP4hZibmDSUbyIBI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hP4hZibmDSUbyIBI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hP4hZibmDSUbyIBI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hP4hZibmDSUbyIBI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hP4hZibmDSUbyIBI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hP4hZibmDSUbyIBI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hP4hZibmDSUbyIBI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hP4hZibmDSUbyIBI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hP4hZibmDSUbyIBI .marker.cross{stroke:#333333;}#mermaid-svg-hP4hZibmDSUbyIBI svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hP4hZibmDSUbyIBI p{margin:0;}#mermaid-svg-hP4hZibmDSUbyIBI .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-hP4hZibmDSUbyIBI .cluster-label text{fill:#333;}#mermaid-svg-hP4hZibmDSUbyIBI .cluster-label span{color:#333;}#mermaid-svg-hP4hZibmDSUbyIBI .cluster-label span p{background-color:transparent;}#mermaid-svg-hP4hZibmDSUbyIBI .label text,#mermaid-svg-hP4hZibmDSUbyIBI span{fill:#333;color:#333;}#mermaid-svg-hP4hZibmDSUbyIBI .node rect,#mermaid-svg-hP4hZibmDSUbyIBI .node circle,#mermaid-svg-hP4hZibmDSUbyIBI .node ellipse,#mermaid-svg-hP4hZibmDSUbyIBI .node polygon,#mermaid-svg-hP4hZibmDSUbyIBI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hP4hZibmDSUbyIBI .rough-node .label text,#mermaid-svg-hP4hZibmDSUbyIBI .node .label text,#mermaid-svg-hP4hZibmDSUbyIBI .image-shape .label,#mermaid-svg-hP4hZibmDSUbyIBI .icon-shape .label{text-anchor:middle;}#mermaid-svg-hP4hZibmDSUbyIBI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hP4hZibmDSUbyIBI .rough-node .label,#mermaid-svg-hP4hZibmDSUbyIBI .node .label,#mermaid-svg-hP4hZibmDSUbyIBI .image-shape .label,#mermaid-svg-hP4hZibmDSUbyIBI .icon-shape .label{text-align:center;}#mermaid-svg-hP4hZibmDSUbyIBI .node.clickable{cursor:pointer;}#mermaid-svg-hP4hZibmDSUbyIBI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hP4hZibmDSUbyIBI .arrowheadPath{fill:#333333;}#mermaid-svg-hP4hZibmDSUbyIBI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hP4hZibmDSUbyIBI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hP4hZibmDSUbyIBI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hP4hZibmDSUbyIBI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hP4hZibmDSUbyIBI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hP4hZibmDSUbyIBI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hP4hZibmDSUbyIBI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hP4hZibmDSUbyIBI .cluster text{fill:#333;}#mermaid-svg-hP4hZibmDSUbyIBI .cluster span{color:#333;}#mermaid-svg-hP4hZibmDSUbyIBI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hP4hZibmDSUbyIBI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hP4hZibmDSUbyIBI rect.text{fill:none;stroke-width:0;}#mermaid-svg-hP4hZibmDSUbyIBI .icon-shape,#mermaid-svg-hP4hZibmDSUbyIBI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hP4hZibmDSUbyIBI .icon-shape p,#mermaid-svg-hP4hZibmDSUbyIBI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hP4hZibmDSUbyIBI .icon-shape .label rect,#mermaid-svg-hP4hZibmDSUbyIBI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hP4hZibmDSUbyIBI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hP4hZibmDSUbyIBI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hP4hZibmDSUbyIBI :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
initState
_simulateLevel
等待 100ms
mounted?
更新 _pitch
更新 _roll
计算 _isLevel
setState 重建 UI
停止循环
关键点:当前 pitch 和 roll 是模拟数据,不来自真实传感器。这个设计适合验证 UI、绘图和状态映射。
六、水平状态与颜色规则
6.1 _isLevel 判断
dart
_isLevel = _pitch.abs() < 1 && _roll.abs() < 1;
当俯仰角和横滚角都小于 1 度时,页面认为当前处于水平状态。
6.2 _getBubbleColor 方法
dart
Color _getBubbleColor() {
if (_isLevel) return Colors.green;
if (_pitch.abs() < 3 && _roll.abs() < 3) return Colors.yellow;
return Colors.red;
}
气泡颜色分三档:
| 条件 | 颜色 | 含义 |
|---|---|---|
_isLevel == true |
绿色 | 已接近水平 |
| pitch/roll 都小于 3 度 | 黄色 | 接近水平 |
| 其他情况 | 红色 | 倾斜明显 |
6.3 状态文本
dart
Text(
_isLevel ? 'LEVEL!' : 'Not Level',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: _isLevel ? Colors.green : Colors.red,
),
)
状态文案和颜色同步变化:水平时展示绿色 LEVEL!,否则展示红色 Not Level。
七、页面布局与展示
7.1 Scaffold 页面骨架
dart
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Column(...),
);
页面主体使用 Column,上半部分展示圆形水平仪,下半部分展示横向水平仪。
7.2 主区域布局
dart
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_isLevel ? 'LEVEL!' : 'Not Level'),
const SizedBox(height: 32),
SizedBox(
width: 250,
height: 250,
child: CustomPaint(...),
),
const SizedBox(height: 32),
Text('Pitch: ${_pitch.toStringAsFixed(1)}°'),
Text('Roll: ${_roll.toStringAsFixed(1)}°'),
],
),
),
)
主区域从上到下依次是状态文本、圆形水平仪、pitch 文本和 roll 文本。
7.3 数值文本
dart
Text(
'Pitch: ${_pitch.toStringAsFixed(1)}°',
style: const TextStyle(fontSize: 20, fontFamily: 'monospace'),
)
toStringAsFixed(1) 保留 1 位小数,monospace 让数字变化时宽度更稳定。
7.4 底部横向水平仪
dart
Container(
padding: const EdgeInsets.all(16),
color: Colors.grey.shade100,
child: Column(
children: [
const Text(
'Horizontal Level',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
SizedBox(
height: 60,
child: CustomPaint(
painter: HorizontalLevelPainter(roll: _roll),
),
),
],
),
)
底部区域只依赖 roll,用于表现左右倾斜。
八、BubbleLevelPainter 基础结构
8.1 类定义
dart
class BubbleLevelPainter extends CustomPainter {
final double pitch;
final double roll;
final Color bubbleColor;
BubbleLevelPainter({
required this.pitch,
required this.roll,
required this.bubbleColor,
});
}
BubbleLevelPainter 接收 pitch、roll 和气泡颜色,负责绘制圆形水平仪。
8.2 shouldRepaint
dart
@override
bool shouldRepaint(covariant BubbleLevelPainter oldDelegate) {
return oldDelegate.pitch != pitch ||
oldDelegate.roll != roll ||
oldDelegate.bubbleColor != bubbleColor;
}
只要 pitch、roll 或颜色发生变化,圆形水平仪就需要重绘。
8.3 画布中心与半径
dart
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 10;
画布中心是水平仪圆心,半径比画布一半少 10,给描边留出空间。
九、绘制圆形水平仪
9.1 绘制外圆
dart
final outerPaint = Paint()
..color = Colors.grey.shade300
..style = PaintingStyle.stroke
..strokeWidth = 3;
canvas.drawCircle(center, radius, outerPaint);
外圆是圆形水平仪的边界。
9.2 绘制十字准线
dart
final crosshairPaint = Paint()
..color = Colors.grey.shade400
..style = PaintingStyle.stroke
..strokeWidth = 1;
canvas.drawLine(
Offset(center.dx - radius, center.dy),
Offset(center.dx + radius, center.dy),
crosshairPaint,
);
canvas.drawLine(
Offset(center.dx, center.dy - radius),
Offset(center.dx, center.dy + radius),
crosshairPaint,
);
十字准线帮助用户判断气泡相对于中心的位置。
9.3 绘制目标圆
dart
final targetPaint = Paint()
..color = Colors.grey.shade400
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawCircle(center, 30, targetPaint);
目标圆表示水平状态的理想范围。气泡越接近中心,越接近水平。
十、圆形气泡位置映射
10.1 计算最大偏移
dart
final maxOffset = radius - 40;
maxOffset 控制气泡在圆形水平仪内移动的最大距离,避免气泡超出外圆。
10.2 pitch 和 roll 到坐标
dart
final bubbleX = center.dx + (roll / 5) * maxOffset;
final bubbleY = center.dy + (pitch / 5) * maxOffset;
这里用线性映射将姿态角转成气泡坐标:
| 数据 | 影响方向 | 映射公式 |
|---|---|---|
roll |
左右移动 | center.dx + (roll / 5) * maxOffset |
pitch |
上下移动 | center.dy + (pitch / 5) * maxOffset |
10.3 绘制气泡
dart
final bubblePaint = Paint()
..color = bubbleColor
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(bubbleX, bubbleY), 20, bubblePaint);
气泡半径为 20,颜色由 _getBubbleColor() 决定。
10.4 绘制气泡描边
dart
final bubbleOutlinePaint = Paint()
..color = bubbleColor.withValues(alpha: 0.5)
..style = PaintingStyle.stroke
..strokeWidth = 3;
canvas.drawCircle(Offset(bubbleX, bubbleY), 20, bubbleOutlinePaint);
描边使用相同颜色的半透明版本,增强气泡层次。
十一、HorizontalLevelPainter 基础结构
11.1 类定义
dart
class HorizontalLevelPainter extends CustomPainter {
final double roll;
HorizontalLevelPainter({required this.roll});
}
横向水平仪只关注左右倾斜,因此只接收 roll。
11.2 shouldRepaint
dart
@override
bool shouldRepaint(covariant HorizontalLevelPainter oldDelegate) {
return oldDelegate.roll != roll;
}
当 roll 变化时,横向水平仪需要重绘。
11.3 中心点计算
dart
final center = Offset(size.width / 2, size.height / 2);
横向水平仪以画布中心作为气泡归零位置。
十二、绘制横向水平仪
12.1 绘制背景槽
dart
final bgPaint = Paint()
..color = Colors.grey.shade200
..style = PaintingStyle.fill;
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(0, 10, size.width, 40),
const Radius.circular(8),
),
bgPaint,
);
横向背景使用圆角矩形绘制,类似传统水平仪的气泡槽。
12.2 绘制中心标记
dart
final markPaint = Paint()
..color = Colors.grey.shade600
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawLine(
Offset(center.dx, 15),
Offset(center.dx, 45),
markPaint,
);
中心标记表示水平位置。气泡越接近这条线,越接近水平。
12.3 横向气泡偏移
dart
final bubbleOffset = (roll / 5) * (size.width / 2 - 30);
final clampedOffset = bubbleOffset.clamp(-size.width / 2 + 40, size.width / 2 - 40);
bubbleOffset 根据 roll 计算偏移,clamp 限制气泡不会移动到容器外。
12.4 横向气泡颜色
dart
Color bubbleColor;
if (roll.abs() < 1) {
bubbleColor = Colors.green;
} else if (roll.abs() < 3) {
bubbleColor = Colors.yellow;
} else {
bubbleColor = Colors.red;
}
横向水平仪只根据 roll 判断颜色。
十三、OpenHarmony 适配边界
13.1 Flutter 层职责
当前项目的状态模拟和绘图逻辑全部在 Flutter 层完成。
text
Flutter 层:
LevelToolApp
LevelToolHomePage
_LevelToolHomePageState
BubbleLevelPainter
HorizontalLevelPainter
CustomPaint
Canvas
Paint
RRect
OpenHarmony 层:
AppScope
entry
EntryAbility
Index
resources
Flutter 层负责 UI 和模拟姿态变化;OpenHarmony 层负责应用启动、模块配置和页面承载。
13.2 平台侧文件作用
| 层级 | 文件 | 作用 |
|---|---|---|
| 应用级 | AppScope/app.json5 |
描述应用整体信息 |
| 模块级 | entry/src/main/module.json5 |
描述模块入口和页面 |
| Ability | EntryAbility.ets |
应用启动入口 |
| 页面 | Index.ets |
承载 Flutter 页面 |
| 资源 | resources/base |
图标、字符串和配置资源 |
| 插件 | GeneratedPluginRegistrant.ets |
插件注册入口 |
13.3 传感器能力边界
当前项目没有读取真实传感器,因此不需要加速度计、陀螺仪或姿态传感器权限。后续若接入真实水平仪能力,需要考虑:
- OpenHarmony 侧传感器权限和能力声明。
- Flutter 与平台侧的数据通道。
- 加速度计、陀螺仪或姿态数据的采样频率。
- pitch/roll 计算与坐标系转换。
- 数据滤波、抖动处理和阈值判断。
- 页面销毁时取消传感器监听。
十四、测试与验证
14.1 页面验证路径
水平仪 Demo 的核心验证路径如下:
- 启动应用,标题显示
Level Tool。 - 页面显示
LEVEL!或Not Level。 - 页面显示圆形气泡水平仪。
- 页面显示
Pitch和Roll数值。 - 等待 100ms 以上,数值持续变化。
- 气泡位置随 pitch/roll 变化。
- 气泡颜色在绿色、黄色、红色之间变化。
- 底部展示
Horizontal Level。 - 横向水平仪气泡随 roll 变化。
14.2 Widget 测试示例
下面的测试验证页面基础内容。
dart
testWidgets('shows level tool page content', (WidgetTester tester) async {
await tester.pumpWidget(const LevelToolApp());
expect(find.text('Level Tool'), findsOneWidget);
expect(find.text('Horizontal Level'), findsOneWidget);
expect(find.byType(CustomPaint), findsWidgets);
});
14.3 水平判断测试
可以将水平判断抽成纯函数后测试:
dart
bool isLevel(double pitch, double roll) {
return pitch.abs() < 1 && roll.abs() < 1;
}
测试用例:
dart
test('detects level state', () {
expect(isLevel(0.5, 0.3), isTrue);
expect(isLevel(2.0, 0.3), isFalse);
expect(isLevel(0.2, 1.5), isFalse);
});
14.4 气泡颜色测试
dart
Color getBubbleColor(double pitch, double roll) {
final level = pitch.abs() < 1 && roll.abs() < 1;
if (level) return Colors.green;
if (pitch.abs() < 3 && roll.abs() < 3) return Colors.yellow;
return Colors.red;
}
测试用例:
dart
test('maps tilt to bubble color', () {
expect(getBubbleColor(0.2, 0.4), Colors.green);
expect(getBubbleColor(2.0, 2.5), Colors.yellow);
expect(getBubbleColor(4.0, 0.5), Colors.red);
});
十五、常见问题与优化建议
15.1 当前项目是否读取真实姿态传感器
没有。当前项目通过 _simulateLevel() 模拟 pitch 和 roll,不读取真实加速度计、陀螺仪或姿态传感器。
15.2 为什么使用 CustomPainter
水平仪不是普通表单或列表,而是由圆形边界、十字准线、目标圆、气泡和横向槽组成。CustomPainter 适合这类需要精确控制绘图细节的场景。
15.3 为什么 pitch 控制纵向,roll 控制横向
在当前 UI 约定中,roll 表示左右倾斜,所以影响 X 坐标;pitch 表示前后俯仰,所以影响 Y 坐标。这让气泡移动方向与姿态含义保持一致。
15.4 为什么要使用 clamp
横向水平仪的气泡不能无限移动。clamp 可以限制偏移范围,避免气泡超出槽体。
dart
final clampedOffset = bubbleOffset.clamp(-size.width / 2 + 40, size.width / 2 - 40);
15.5 为什么颜色分三档
绿色、黄色、红色符合常见状态语义:绿色表示正常,黄色表示接近,红色表示偏差明显。用户不用阅读数值,也能快速判断水平状态。
15.6 接入真实传感器时如何处理抖动
真实传感器数据会有噪声。可以使用低通滤波、滑动平均或阈值更新策略,让气泡移动更稳定。
15.7 UI 层可以如何继续优化
- 增加刻度文字,例如
0°、3°、5°。 - 增加校准按钮,把当前姿态作为零点。
- 增加横屏布局,提升工具类应用可用性。
- 将两个 Painter 拆分到独立文件。
- 加入语义化说明,方便无障碍工具读取状态。
十六、完整业务链路复盘
16.1 状态更新链路
- 页面进入
initState()。 _simulateLevel()启动异步循环。- 每 100ms 等待一次。
- 判断
mounted。 - 更新
_pitch。 - 更新
_roll。 - 计算
_isLevel。 setState触发页面重建。- 状态文案、数值文本、气泡位置和颜色同步变化。
16.2 圆形水平仪绘制链路
CustomPaint创建BubbleLevelPainter。- 计算中心点和半径。
- 绘制外圆。
- 绘制横向和纵向十字准线。
- 绘制目标圆。
- 根据 pitch/roll 计算气泡坐标。
- 绘制气泡填充。
- 绘制气泡描边。
- pitch/roll/color 改变后触发重绘。
16.3 横向水平仪绘制链路
CustomPaint创建HorizontalLevelPainter。- 绘制圆角背景槽。
- 绘制中心标记线。
- 根据 roll 计算气泡偏移。
- 使用
clamp限制偏移范围。 - 根据 roll 选择气泡颜色。
- 绘制横向气泡。
16.4 数据到 UI 的映射
| 数据 | 处理方式 | UI 输出 |
|---|---|---|
_pitch |
toStringAsFixed(1) |
Pitch 文本 |
_roll |
toStringAsFixed(1) |
Roll 文本 |
_pitch + _roll |
_isLevel 判断 |
LEVEL / Not Level |
_pitch + _roll |
_getBubbleColor |
圆形气泡颜色 |
_pitch + _roll |
坐标映射 | 圆形气泡位置 |
_roll |
横向偏移计算 | 横向气泡位置 |
十七、核心源码总览
下面集中展示当前项目最关键的状态模拟和颜色规则代码。
dart
class _LevelToolHomePageState extends State<LevelToolHomePage> {
double _pitch = 0;
double _roll = 0;
bool _isLevel = false;
@override
void initState() {
super.initState();
_simulateLevel();
}
void _simulateLevel() {
Future.doWhile(() async {
await Future.delayed(const Duration(milliseconds: 100));
if (mounted) {
setState(() {
_pitch = (_pitch + 0.3) % 10 - 5;
_roll = (_roll + 0.2) % 8 - 4;
_isLevel = _pitch.abs() < 1 && _roll.abs() < 1;
});
return true;
}
return false;
});
}
Color _getBubbleColor() {
if (_isLevel) return Colors.green;
if (_pitch.abs() < 3 && _roll.abs() < 3) return Colors.yellow;
return Colors.red;
}
}
圆形水平仪的核心气泡位置计算如下:
dart
final maxOffset = radius - 40;
final bubbleX = center.dx + (roll / 5) * maxOffset;
final bubbleY = center.dy + (pitch / 5) * maxOffset;
final bubblePaint = Paint()
..color = bubbleColor
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(bubbleX, bubbleY), 20, bubblePaint);
横向水平仪的核心偏移计算如下:
dart
final bubbleOffset = (roll / 5) * (size.width / 2 - 30);
final clampedOffset = bubbleOffset.clamp(
-size.width / 2 + 40,
size.width / 2 - 40,
);
这几段代码体现了项目主干:模拟姿态数据,判断水平状态,并把 pitch/roll 映射成可视化气泡。
总结
level_tool 是一个很适合学习 Flutter 自定义绘图和传感器类 UI 原型的水平仪 Demo。它通过 _pitch 和 _roll 模拟姿态数据,通过 _isLevel 判断是否接近水平,通过 _getBubbleColor() 把倾斜程度映射为绿色、黄色和红色,再通过两个 CustomPainter 绘制圆形水平仪和横向水平仪。
从源码结构看,项目的技术重点集中在三条线:第一条是状态模拟,_simulateLevel() 每 100ms 更新 pitch 和 roll;第二条是状态映射,水平判断、气泡颜色和文本都由 pitch/roll 派生;第三条是 Canvas 绘图,BubbleLevelPainter 和 HorizontalLevelPainter 分别完成圆形与横向水平仪绘制。
从 OpenHarmony 适配角度看,当前项目业务逻辑保持在 Flutter 层,ohos 目录负责平台工程承载。它没有读取真实传感器,因此适合作为水平仪 UI 和绘图逻辑的入门案例;如果后续接入真实加速度计或陀螺仪,就需要补充权限、平台通道、数据滤波、坐标系转换和生命周期解绑等能力。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
- Flutter 官方文档:https://docs.flutter.dev/
- Flutter 测试文档:https://docs.flutter.dev/testing
- OpenHarmony 官网:https://www.openharmony.cn/
- 开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net