Flutter+OpenHarmony实战level_tool水平仪

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() 模拟 pitchroll 的变化。因此本文会严格基于真实源码,把它定位为 水平仪 UI 与传感器能力扩展案例,既讲清楚当前 Demo 的绘制逻辑,也说明后续接入 OpenHarmony 真机传感器时需要关注的能力边界。

本文重点回答三个问题:

  1. level_tool 如何用 _pitch_roll_isLevel 表达水平仪状态。
  2. Flutter 如何使用 CustomPainterCanvasPaintRRect 绘制圆形与横向水平仪。
  3. 这个项目适配 OpenHarmony 时,如何区分 UI 模拟能力和真实传感器能力。

图示说明:本文聚焦 Flutter 水平仪 UI Demo 的姿态模拟、气泡映射和自定义绘制,核心源码位于 lib/main.dart


一、背景与目标

1.1 项目功能概览

level_tool 当前是一个水平仪视觉模拟项目。页面上方显示 LEVEL!Not Level 状态,中间绘制圆形气泡水平仪,下方展示 PitchRoll 数值,底部还有一个横向水平仪条。

当前项目真实支持的功能包括:

  • 展示 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'),
    );
  }
}

这里有三个关键信息:

  1. 应用标题是 Level Tool
  2. 主题种子色是 Colors.teal,符合工具类应用清爽、稳定的视觉气质。
  3. 首页是 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 传感器能力边界

当前项目没有读取真实传感器,因此不需要加速度计、陀螺仪或姿态传感器权限。后续若接入真实水平仪能力,需要考虑:

  1. OpenHarmony 侧传感器权限和能力声明。
  2. Flutter 与平台侧的数据通道。
  3. 加速度计、陀螺仪或姿态数据的采样频率。
  4. pitch/roll 计算与坐标系转换。
  5. 数据滤波、抖动处理和阈值判断。
  6. 页面销毁时取消传感器监听。

十四、测试与验证

14.1 页面验证路径

水平仪 Demo 的核心验证路径如下:

  1. 启动应用,标题显示 Level Tool
  2. 页面显示 LEVEL!Not Level
  3. 页面显示圆形气泡水平仪。
  4. 页面显示 PitchRoll 数值。
  5. 等待 100ms 以上,数值持续变化。
  6. 气泡位置随 pitch/roll 变化。
  7. 气泡颜色在绿色、黄色、红色之间变化。
  8. 底部展示 Horizontal Level
  9. 横向水平仪气泡随 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 层可以如何继续优化

  • 增加刻度文字,例如
  • 增加校准按钮,把当前姿态作为零点。
  • 增加横屏布局,提升工具类应用可用性。
  • 将两个 Painter 拆分到独立文件。
  • 加入语义化说明,方便无障碍工具读取状态。

十六、完整业务链路复盘

16.1 状态更新链路

  1. 页面进入 initState()
  2. _simulateLevel() 启动异步循环。
  3. 每 100ms 等待一次。
  4. 判断 mounted
  5. 更新 _pitch
  6. 更新 _roll
  7. 计算 _isLevel
  8. setState 触发页面重建。
  9. 状态文案、数值文本、气泡位置和颜色同步变化。

16.2 圆形水平仪绘制链路

  1. CustomPaint 创建 BubbleLevelPainter
  2. 计算中心点和半径。
  3. 绘制外圆。
  4. 绘制横向和纵向十字准线。
  5. 绘制目标圆。
  6. 根据 pitch/roll 计算气泡坐标。
  7. 绘制气泡填充。
  8. 绘制气泡描边。
  9. pitch/roll/color 改变后触发重绘。

16.3 横向水平仪绘制链路

  1. CustomPaint 创建 HorizontalLevelPainter
  2. 绘制圆角背景槽。
  3. 绘制中心标记线。
  4. 根据 roll 计算气泡偏移。
  5. 使用 clamp 限制偏移范围。
  6. 根据 roll 选择气泡颜色。
  7. 绘制横向气泡。

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 绘图,BubbleLevelPainterHorizontalLevelPainter 分别完成圆形与横向水平仪绘制。

从 OpenHarmony 适配角度看,当前项目业务逻辑保持在 Flutter 层,ohos 目录负责平台工程承载。它没有读取真实传感器,因此适合作为水平仪 UI 和绘图逻辑的入门案例;如果后续接入真实加速度计或陀螺仪,就需要补充权限、平台通道、数据滤波、坐标系转换和生命周期解绑等能力。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!


相关资源:

相关推荐
TrisighT1 小时前
uni-app鸿蒙原生应用开发实战(下):核心功能实现与技术细节
vue.js·harmonyos
G_dou_1 小时前
Flutter三方库适配OpenHarmony【dice_roller】骰子投掷器项目完整实战
flutter·harmonyos
痕忆丶2 小时前
openharmony北向开发问题之HDC端口8710被svchost占用问题
harmonyos
AI2中文网2 小时前
App Inventor 2 鸿蒙先行版开发进展:从 Android 到 HarmonyOS 的积木编程迁移实录
android·低代码·华为·harmonyos·app inventor
nashane2 小时前
HarmonyOS 6学习:DevEco Studio跨平台开发环境深度排障指南
学习·华为·harmonyos
韩曙亮2 小时前
【Flutter】Flutter 组件 ① ( StatelessWidget 无状态组件 与 StatefulWidget 有状态组件 )
flutter·statefulwidget·statelesswidget
恋猫de小郭2 小时前
Flutter 最好的 AI 自动化测试工具:Patrol
android·前端·flutter
不爱吃糖的程序媛2 小时前
使用 hionic 将 Web 应用部署到鸿蒙PC平台
flutter·华为·harmonyos