Flutter三方库适配OpenHarmony【compass】罗盘 UI 项目完整实战
前言
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
compass 是一个基于 Flutter 实现的 Compass 罗盘 UI Demo 。项目核心代码集中在 lib/main.dart,同时保留了 ohos OpenHarmony 平台工程目录,适合用来学习 Flutter CustomPainter 绘图 、角度模拟 、方向映射 、Canvas 文本绘制 和 OpenHarmony 平台承载。
需要先说明:当前项目没有接入真实磁力计或方向传感器,而是通过 _heading = (_heading + 0.5) % 360 每 100ms 模拟角度变化。因此本文会严格基于真实源码,把它定位为 罗盘 UI 与传感器能力扩展案例,既讲清楚当前 Demo 的绘制逻辑,也说明 OpenHarmony 真机传感器接入时需要关注的能力边界。
本文重点回答三个问题:
compass如何用_heading和_direction表达罗盘状态。- Flutter 如何使用
CustomPainter、Canvas、Paint、Path和TextPainter绘制罗盘。 - 这个项目适配 OpenHarmony 时,如何区分 UI 模拟能力和真实传感器能力。

图示说明:本文聚焦 Flutter 罗盘 UI Demo 的状态模拟、方向映射和自定义绘制,核心源码位于 lib/main.dart。
一、背景与目标
1.1 项目功能概览
compass 当前是一个罗盘视觉模拟项目。页面顶部展示当前角度和方向文本,中间使用 CustomPaint 绘制罗盘圆盘、方向刻度、N/E/S/W 标签、南北指针和中心点。
当前项目真实支持的功能包括:
- 展示当前角度
_heading。 - 展示当前方向
_direction。 - 维护 16 个方位点:
N、NNE、NE、ENE、E等。 - 每 100ms 模拟方向增加 0.5 度。
- 使用
Future.doWhile构造持续刷新循环。 - 使用
mounted防止页面销毁后继续setState。 - 使用
CustomPaint承载罗盘绘制。 - 使用
Canvas.drawCircle绘制外圆和中心点。 - 使用
Canvas.drawLine绘制方向刻度。 - 使用
TextPainter绘制 N/E/S/W 标签。 - 使用
Path绘制红色北指针和灰色南指针。 - 使用
shouldRepaint根据heading变化决定重绘。
1.2 技术关键词
| 关键词 | 在项目中的作用 |
|---|---|
dart:math |
提供 sin、cos、pi 等三角函数能力 |
Future.doWhile |
模拟罗盘角度持续变化 |
Future.delayed |
控制刷新间隔为 100ms |
mounted |
保护异步循环中的 setState |
CustomPainter |
封装罗盘绘制逻辑 |
Canvas |
绘制圆、线、路径和文本 |
Paint |
配置颜色、线宽和填充样式 |
Path |
绘制三角形指针 |
TextPainter |
在 Canvas 上绘制方向文字 |
shouldRepaint |
判断 heading 变化后是否重绘 |
1.3 Demo 能力边界
| 能力 | 当前是否实现 | 说明 |
|---|---|---|
| 罗盘 UI 绘制 | 是 | 使用 CustomPainter |
| 角度持续变化 | 是 | 使用模拟角度 |
| 16 方位映射 | 是 | 使用 _directions 列表 |
| 真实磁力计读取 | 否 | 当前未接入传感器 |
| OpenHarmony 传感器权限 | 否 | 当前不需要权限声明 |
| 真机校准能力 | 否 | 页面只有提示文案 |
关键点:当前项目是罗盘 UI Demo,不能写成真实指南针硬件应用。它的价值在于演示角度、方向文本和 Canvas 绘图如何协同。
二、环境准备
2.1 项目目录结构
当前项目采用 Flutter 标准目录,同时包含 OpenHarmony 平台工程。
bash
compass/
├── 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 基础配置
项目名称是 compass,版本是 1.0.0+1,Dart SDK 约束是 ^3.9.2。
yaml
name: compass
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 标准库,不需要写入 pubspec.yaml。
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,后续绘制刻度和标签时会使用 math.pi、math.cos、math.sin。
3.2 main 函数
dart
void main() {
runApp(const CompassApp());
}
runApp(const CompassApp()) 将 CompassApp 放入 Flutter 渲染树根节点。
3.3 CompassApp
dart
class CompassApp extends StatelessWidget {
const CompassApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Compass',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.brown),
useMaterial3: true,
),
home: const CompassHomePage(title: 'Compass'),
);
}
}
这里有三个关键信息:
- 应用标题是
Compass。 - 主题种子色是
Colors.brown,比较贴合工具类罗盘的视觉氛围。 - 首页是
CompassHomePage,罗盘角度状态从首页 State 中维护。
四、页面状态设计
4.1 CompassHomePage
dart
class CompassHomePage extends StatefulWidget {
const CompassHomePage({super.key, required this.title});
final String title;
@override
State<CompassHomePage> createState() => _CompassHomePageState();
}
罗盘角度会持续变化,因此页面使用 StatefulWidget。
4.2 核心状态字段
dart
class _CompassHomePageState extends State<CompassHomePage> {
double _heading = 0;
String _direction = 'N';
}
| 字段 | 类型 | 作用 |
|---|---|---|
_heading |
double |
当前罗盘角度,范围 0 到 360 |
_direction |
String |
当前方向文本,如 N、NE、S |
4.3 状态与 UI 的关系
#mermaid-svg-BAi3JzoKzG1328M7{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-BAi3JzoKzG1328M7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-BAi3JzoKzG1328M7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-BAi3JzoKzG1328M7 .error-icon{fill:#552222;}#mermaid-svg-BAi3JzoKzG1328M7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-BAi3JzoKzG1328M7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-BAi3JzoKzG1328M7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-BAi3JzoKzG1328M7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-BAi3JzoKzG1328M7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-BAi3JzoKzG1328M7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-BAi3JzoKzG1328M7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-BAi3JzoKzG1328M7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-BAi3JzoKzG1328M7 .marker.cross{stroke:#333333;}#mermaid-svg-BAi3JzoKzG1328M7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-BAi3JzoKzG1328M7 p{margin:0;}#mermaid-svg-BAi3JzoKzG1328M7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-BAi3JzoKzG1328M7 .cluster-label text{fill:#333;}#mermaid-svg-BAi3JzoKzG1328M7 .cluster-label span{color:#333;}#mermaid-svg-BAi3JzoKzG1328M7 .cluster-label span p{background-color:transparent;}#mermaid-svg-BAi3JzoKzG1328M7 .label text,#mermaid-svg-BAi3JzoKzG1328M7 span{fill:#333;color:#333;}#mermaid-svg-BAi3JzoKzG1328M7 .node rect,#mermaid-svg-BAi3JzoKzG1328M7 .node circle,#mermaid-svg-BAi3JzoKzG1328M7 .node ellipse,#mermaid-svg-BAi3JzoKzG1328M7 .node polygon,#mermaid-svg-BAi3JzoKzG1328M7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-BAi3JzoKzG1328M7 .rough-node .label text,#mermaid-svg-BAi3JzoKzG1328M7 .node .label text,#mermaid-svg-BAi3JzoKzG1328M7 .image-shape .label,#mermaid-svg-BAi3JzoKzG1328M7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-BAi3JzoKzG1328M7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-BAi3JzoKzG1328M7 .rough-node .label,#mermaid-svg-BAi3JzoKzG1328M7 .node .label,#mermaid-svg-BAi3JzoKzG1328M7 .image-shape .label,#mermaid-svg-BAi3JzoKzG1328M7 .icon-shape .label{text-align:center;}#mermaid-svg-BAi3JzoKzG1328M7 .node.clickable{cursor:pointer;}#mermaid-svg-BAi3JzoKzG1328M7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-BAi3JzoKzG1328M7 .arrowheadPath{fill:#333333;}#mermaid-svg-BAi3JzoKzG1328M7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-BAi3JzoKzG1328M7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-BAi3JzoKzG1328M7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BAi3JzoKzG1328M7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-BAi3JzoKzG1328M7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BAi3JzoKzG1328M7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-BAi3JzoKzG1328M7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-BAi3JzoKzG1328M7 .cluster text{fill:#333;}#mermaid-svg-BAi3JzoKzG1328M7 .cluster span{color:#333;}#mermaid-svg-BAi3JzoKzG1328M7 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-BAi3JzoKzG1328M7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-BAi3JzoKzG1328M7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-BAi3JzoKzG1328M7 .icon-shape,#mermaid-svg-BAi3JzoKzG1328M7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BAi3JzoKzG1328M7 .icon-shape p,#mermaid-svg-BAi3JzoKzG1328M7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-BAi3JzoKzG1328M7 .icon-shape .label rect,#mermaid-svg-BAi3JzoKzG1328M7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BAi3JzoKzG1328M7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-BAi3JzoKzG1328M7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-BAi3JzoKzG1328M7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} _heading
角度文本
CompassPainter
_getDirection
_direction
方向文本
_heading 是页面的核心状态。角度文本、方向文本和罗盘绘制都围绕它变化。
五、16 方位数据结构
5.1 _directions 列表
当前项目定义了 16 个方向点。
dart
final List<Map<String, dynamic>> _directions = [
{'name': 'N', 'angle': 0},
{'name': 'NNE', 'angle': 22.5},
{'name': 'NE', 'angle': 45},
{'name': 'ENE', 'angle': 67.5},
{'name': 'E', 'angle': 90},
{'name': 'ESE', 'angle': 112.5},
{'name': 'SE', 'angle': 135},
{'name': 'SSE', 'angle': 157.5},
{'name': 'S', 'angle': 180},
{'name': 'SSW', 'angle': 202.5},
{'name': 'SW', 'angle': 225},
{'name': 'WSW', 'angle': 247.5},
{'name': 'W', 'angle': 270},
{'name': 'WNW', 'angle': 292.5},
{'name': 'NW', 'angle': 315},
{'name': 'NNW', 'angle': 337.5},
];
5.2 方位表
| 方向 | 角度 |
|---|---|
| N | 0 |
| NNE | 22.5 |
| NE | 45 |
| ENE | 67.5 |
| E | 90 |
| ESE | 112.5 |
| SE | 135 |
| SSE | 157.5 |
| S | 180 |
| SSW | 202.5 |
| SW | 225 |
| WSW | 247.5 |
| W | 270 |
| WNW | 292.5 |
| NW | 315 |
| NNW | 337.5 |
5.3 为什么是 22.5 度间隔
360 度均分成 16 个方向,每个方向间隔是:
text
360 / 16 = 22.5
每个方向的半区间是 11.25 度,所以 _getDirection 使用 < 11.25 判断当前角度靠近哪个方向点。
六、方向模拟逻辑
6.1 initState 启动模拟
dart
@override
void initState() {
super.initState();
_simulateHeading();
}
页面初始化时启动 _simulateHeading(),让罗盘开始持续转动。
6.2 _simulateHeading 方法
dart
void _simulateHeading() {
Future.doWhile(() async {
await Future.delayed(const Duration(milliseconds: 100));
if (mounted) {
setState(() {
_heading = (_heading + 0.5) % 360;
_direction = _getDirection(_heading);
});
return true;
}
return false;
});
}
这段代码每 100ms 更新一次角度。(_heading + 0.5) % 360 让角度不断增加,并在超过 360 度后回到 0 到 360 的范围内。
6.3 刷新流程
#mermaid-svg-eZZZqIFozGZHmEWq{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-eZZZqIFozGZHmEWq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-eZZZqIFozGZHmEWq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-eZZZqIFozGZHmEWq .error-icon{fill:#552222;}#mermaid-svg-eZZZqIFozGZHmEWq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-eZZZqIFozGZHmEWq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-eZZZqIFozGZHmEWq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-eZZZqIFozGZHmEWq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-eZZZqIFozGZHmEWq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-eZZZqIFozGZHmEWq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-eZZZqIFozGZHmEWq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-eZZZqIFozGZHmEWq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-eZZZqIFozGZHmEWq .marker.cross{stroke:#333333;}#mermaid-svg-eZZZqIFozGZHmEWq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-eZZZqIFozGZHmEWq p{margin:0;}#mermaid-svg-eZZZqIFozGZHmEWq .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-eZZZqIFozGZHmEWq .cluster-label text{fill:#333;}#mermaid-svg-eZZZqIFozGZHmEWq .cluster-label span{color:#333;}#mermaid-svg-eZZZqIFozGZHmEWq .cluster-label span p{background-color:transparent;}#mermaid-svg-eZZZqIFozGZHmEWq .label text,#mermaid-svg-eZZZqIFozGZHmEWq span{fill:#333;color:#333;}#mermaid-svg-eZZZqIFozGZHmEWq .node rect,#mermaid-svg-eZZZqIFozGZHmEWq .node circle,#mermaid-svg-eZZZqIFozGZHmEWq .node ellipse,#mermaid-svg-eZZZqIFozGZHmEWq .node polygon,#mermaid-svg-eZZZqIFozGZHmEWq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-eZZZqIFozGZHmEWq .rough-node .label text,#mermaid-svg-eZZZqIFozGZHmEWq .node .label text,#mermaid-svg-eZZZqIFozGZHmEWq .image-shape .label,#mermaid-svg-eZZZqIFozGZHmEWq .icon-shape .label{text-anchor:middle;}#mermaid-svg-eZZZqIFozGZHmEWq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-eZZZqIFozGZHmEWq .rough-node .label,#mermaid-svg-eZZZqIFozGZHmEWq .node .label,#mermaid-svg-eZZZqIFozGZHmEWq .image-shape .label,#mermaid-svg-eZZZqIFozGZHmEWq .icon-shape .label{text-align:center;}#mermaid-svg-eZZZqIFozGZHmEWq .node.clickable{cursor:pointer;}#mermaid-svg-eZZZqIFozGZHmEWq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-eZZZqIFozGZHmEWq .arrowheadPath{fill:#333333;}#mermaid-svg-eZZZqIFozGZHmEWq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-eZZZqIFozGZHmEWq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-eZZZqIFozGZHmEWq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eZZZqIFozGZHmEWq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-eZZZqIFozGZHmEWq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eZZZqIFozGZHmEWq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-eZZZqIFozGZHmEWq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-eZZZqIFozGZHmEWq .cluster text{fill:#333;}#mermaid-svg-eZZZqIFozGZHmEWq .cluster span{color:#333;}#mermaid-svg-eZZZqIFozGZHmEWq 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-eZZZqIFozGZHmEWq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-eZZZqIFozGZHmEWq rect.text{fill:none;stroke-width:0;}#mermaid-svg-eZZZqIFozGZHmEWq .icon-shape,#mermaid-svg-eZZZqIFozGZHmEWq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eZZZqIFozGZHmEWq .icon-shape p,#mermaid-svg-eZZZqIFozGZHmEWq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-eZZZqIFozGZHmEWq .icon-shape .label rect,#mermaid-svg-eZZZqIFozGZHmEWq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eZZZqIFozGZHmEWq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-eZZZqIFozGZHmEWq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-eZZZqIFozGZHmEWq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
initState
_simulateHeading
等待 100ms
mounted?
_heading + 0.5
% 360 归一化
_direction = _getDirection(_heading)
setState 重建 UI
停止循环
关键点:当前角度来自模拟逻辑,不来自真实传感器。这个设计适合 UI Demo 和绘图验证。
七、方向文本映射
7.1 _getDirection 方法
dart
String _getDirection(double heading) {
for (final dir in _directions) {
if ((heading - dir['angle']).abs() < 11.25) {
return dir['name'];
}
}
return 'N';
}
这个方法遍历 16 个方向点,如果当前角度与某个方向点的差值小于 11.25 度,就返回该方向名称。
7.2 方向判断示例
| heading | 匹配方向 |
|---|---|
| 0.0 | N |
| 23.0 | NNE |
| 46.0 | NE |
| 91.0 | E |
| 181.0 | S |
| 270.0 | W |
| 337.0 | NNW |
7.3 边界处理
方法最后返回 N:
dart
return 'N';
由于 0 度附近存在跨 360 度的边界,return 'N' 能让未命中的极端角度回到北方文本。当前模拟角度由 % 360 归一化,因此整体显示会稳定循环。
八、页面布局与展示
8.1 Scaffold 页面骨架
dart
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Center(
child: Column(...),
),
);
页面使用 Scaffold 提供应用栏和主体区域。
8.2 角度文本
dart
Text(
'${_heading.toStringAsFixed(1)}°',
style: const TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
)
toStringAsFixed(1) 将角度保留 1 位小数,例如 12.5°。monospace 让数字变化时宽度更稳定。
8.3 方向文本
dart
Text(
_direction,
style: TextStyle(
fontSize: 24,
color: Colors.grey.shade600,
),
)
方向文本显示在角度下方,字号较小,作为角度的辅助说明。
8.4 罗盘绘制区域
dart
SizedBox(
width: 280,
height: 280,
child: CustomPaint(
painter: CompassPainter(heading: _heading),
),
)
CustomPaint 使用 CompassPainter 绘制罗盘,外层 SizedBox 固定画布尺寸为 280 x 280。
九、CustomPainter 基础结构
9.1 CompassPainter 类
dart
class CompassPainter extends CustomPainter {
final double heading;
CompassPainter({required this.heading});
@override
void paint(Canvas canvas, Size size) {
// 绘制逻辑
}
@override
bool shouldRepaint(covariant CompassPainter oldDelegate) {
return oldDelegate.heading != heading;
}
}
CompassPainter 接收 heading,并在 paint 中根据角度绘制罗盘。
9.2 shouldRepaint
dart
bool shouldRepaint(covariant CompassPainter oldDelegate) {
return oldDelegate.heading != heading;
}
当新的 heading 和旧的 heading 不一致时,罗盘需要重绘。由于 _heading 每 100ms 改变一次,CustomPaint 会持续刷新。
9.3 画布中心和半径
dart
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 20;
画布中心是罗盘中心,半径比画布宽度一半少 20,给外圈和标签留出空间。
十、绘制罗盘外圈与刻度
10.1 绘制外圆
dart
final outerPaint = Paint()
..color = Colors.grey.shade300
..style = PaintingStyle.stroke
..strokeWidth = 4;
canvas.drawCircle(center, radius, outerPaint);
这里使用描边样式绘制罗盘外圈。
10.2 刻度 Paint
dart
final markerPaint = Paint()
..color = Colors.grey.shade800
..style = PaintingStyle.stroke
..strokeWidth = 2;
markerPaint 用于绘制 N/E/S/W 四个方向上的刻度线。
10.3 方向刻度计算
dart
final directions = ['N', 'E', 'S', 'W'];
final anglePerDirection = 90;
for (int i = 0; i < 4; i++) {
final angle = (i * anglePerDirection - heading) * math.pi / 180 - math.pi / 2;
final x = center.dx + radius * math.cos(angle);
final y = center.dy + radius * math.sin(angle);
final innerX = center.dx + (radius - 30) * math.cos(angle);
final innerY = center.dy + (radius - 30) * math.sin(angle);
canvas.drawLine(Offset(innerX, innerY), Offset(x, y), markerPaint);
}
核心公式是将角度转换成弧度,并使用三角函数计算坐标。
10.4 角度转弧度
text
radian = degree * pi / 180
Flutter Canvas 的三角函数使用弧度,因此角度必须转换为弧度。
十一、绘制方向文字
11.1 TextSpan
dart
final textSpan = TextSpan(
text: directions[i],
style: TextStyle(
color: directions[i] == 'N' ? Colors.red : Colors.black87,
fontSize: 20,
fontWeight: FontWeight.bold,
),
);
方向文字中,N 使用红色,其他方向使用黑色。
11.2 TextPainter
dart
final textPainter = TextPainter(
text: textSpan,
textDirection: TextDirection.ltr,
);
textPainter.layout();
TextPainter 需要先调用 layout(),才能得到文字宽高并绘制。
11.3 标签位置计算
dart
final labelRadius = radius - 50;
final labelX = center.dx + labelRadius * math.cos(angle) - textPainter.width / 2;
final labelY = center.dy + labelRadius * math.sin(angle) - textPainter.height / 2;
textPainter.paint(canvas, Offset(labelX, labelY));
减去文字宽高的一半,是为了让文字中心对齐到计算出来的位置。
十二、绘制南北指针
12.1 北指针路径
dart
final northPath = Path();
northPath.moveTo(center.dx, center.dy - radius + 40);
northPath.lineTo(center.dx - 10, center.dy);
northPath.lineTo(center.dx + 10, center.dy);
northPath.close();
final northPaint = Paint()
..color = Colors.red
..style = PaintingStyle.fill;
canvas.drawPath(northPath, northPaint);
北指针是一个红色三角形,从顶部指向中心。
12.2 南指针路径
dart
final southPath = Path();
southPath.moveTo(center.dx, center.dy + radius - 40);
southPath.lineTo(center.dx - 10, center.dy);
southPath.lineTo(center.dx + 10, center.dy);
southPath.close();
final southPaint = Paint()
..color = Colors.grey.shade400
..style = PaintingStyle.fill;
canvas.drawPath(southPath, southPaint);
南指针是灰色三角形,从底部指向中心。
12.3 中心点
dart
final centerPaint = Paint()
..color = Colors.grey.shade800
..style = PaintingStyle.fill;
canvas.drawCircle(center, 8, centerPaint);
中心点用于压住两组三角指针的交汇处,让视觉更完整。
十三、绘图数学拆解
13.1 坐标计算公式
罗盘刻度和方向文字都使用圆周坐标公式:
text
x = centerX + radius * cos(angle)
y = centerY + radius * sin(angle)
13.2 为什么减去 heading
dart
final angle = (i * anglePerDirection - heading) * math.pi / 180 - math.pi / 2;
i * anglePerDirection 表示 N/E/S/W 的基础角度,减去 heading 表示罗盘随着当前方向旋转。最后 - math.pi / 2 是为了让 0 度方向从屏幕上方开始,而不是从右侧开始。
13.3 绘图元素表
| 元素 | API | 作用 |
|---|---|---|
| 外圈 | drawCircle |
罗盘边界 |
| 刻度线 | drawLine |
标记主方向 |
| 方向文字 | TextPainter.paint |
绘制 N/E/S/W |
| 北指针 | drawPath |
红色三角形 |
| 南指针 | drawPath |
灰色三角形 |
| 中心点 | drawCircle |
覆盖指针交汇点 |
十四、OpenHarmony 适配边界
14.1 Flutter 层职责
当前项目的状态模拟和绘图逻辑全部在 Flutter 层完成。
text
Flutter 层:
CompassApp
CompassHomePage
_CompassHomePageState
CompassPainter
CustomPaint
Canvas
Paint
Path
TextPainter
OpenHarmony 层:
AppScope
entry
EntryAbility
Index
resources
Flutter 层负责 UI 和模拟方向变化;OpenHarmony 层负责应用启动、模块配置和页面承载。
14.2 平台侧文件作用
| 层级 | 文件 | 作用 |
|---|---|---|
| 应用级 | AppScope/app.json5 |
描述应用整体信息 |
| 模块级 | entry/src/main/module.json5 |
描述模块入口和页面 |
| Ability | EntryAbility.ets |
应用启动入口 |
| 页面 | Index.ets |
承载 Flutter 页面 |
| 资源 | resources/base |
图标、字符串和配置资源 |
| 插件 | GeneratedPluginRegistrant.ets |
插件注册入口 |
14.3 传感器能力边界
当前项目没有读取真实传感器,因此无需定位、磁力计或方向传感器权限。后续若接入真实罗盘能力,需要考虑:
- OpenHarmony 侧传感器权限和能力声明。
- Flutter 与平台侧的数据通道。
- 传感器回调频率和 UI 刷新频率。
- 方向角度的滤波、抖动处理和校准提示。
- 页面销毁时取消传感器监听。
十五、测试与验证
15.1 页面验证路径
罗盘 Demo 的核心验证路径如下:
- 启动应用,标题显示
Compass。 - 页面显示角度文本,例如
0.0°。 - 页面显示方向文本,例如
N。 - 等待 100ms 以上,角度开始变化。
- 角度变化后方向文本随之更新。
- 罗盘圆盘持续重绘。
- N/E/S/W 标签可见。
- 红色北指针、灰色南指针和中心点可见。
- 页面底部显示
Tap anywhere to calibrate文案。
15.2 Widget 测试示例
下面的测试验证页面基础元素。
dart
testWidgets('shows compass page content', (WidgetTester tester) async {
await tester.pumpWidget(const CompassApp());
expect(find.text('Compass'), findsOneWidget);
expect(find.text('N'), findsOneWidget);
expect(find.text('Tap anywhere to calibrate'), findsOneWidget);
expect(find.byType(CustomPaint), findsOneWidget);
});
15.3 方向映射测试示例
如果把方向映射抽成纯函数,可以这样测试:
dart
String getDirection(double heading) {
final directions = [
{'name': 'N', 'angle': 0.0},
{'name': 'NE', 'angle': 45.0},
{'name': 'E', 'angle': 90.0},
{'name': 'S', 'angle': 180.0},
{'name': 'W', 'angle': 270.0},
];
for (final dir in directions) {
if ((heading - (dir['angle'] as double)).abs() < 22.5) {
return dir['name'] as String;
}
}
return 'N';
}
测试用例:
dart
test('maps heading to direction', () {
expect(getDirection(0), 'N');
expect(getDirection(44), 'NE');
expect(getDirection(91), 'E');
});
15.4 CustomPainter 重绘验证
可以通过 shouldRepaint 的返回值验证 heading 变化会触发重绘:
dart
test('repaints when heading changes', () {
final oldPainter = CompassPainter(heading: 10);
final newPainter = CompassPainter(heading: 20);
expect(newPainter.shouldRepaint(oldPainter), isTrue);
});
十六、常见问题与优化建议
16.1 当前项目是否读取真实指南针数据
没有。当前项目通过 _simulateHeading() 模拟角度变化,不读取真实磁力计或方向传感器。
16.2 为什么使用 CustomPainter
罗盘不是普通列表或按钮,而是由圆、线、文字、路径等元素组合而成。CustomPainter 适合这类需要精细控制绘图的场景。
16.3 为什么使用 TextPainter 绘制文字
Canvas 本身不直接提供普通 Widget 文本布局能力。要在 Canvas 上绘制文字,需要使用 TextPainter 进行布局和绘制。
16.4 为什么刷新间隔是 100ms
100ms 能让模拟旋转看起来比较平滑,同时不会像毫秒级刷新那样频繁重建。对于 UI Demo 来说,这是一个轻量的折中。
16.5 方向边界如何进一步优化
当前 _getDirection 通过与方向点差值小于 11.25 度判断方向。对于 360 度附近的跨边界情况,可以封装更完整的环形距离计算,让 359° 更明确地落到 N 附近。
16.6 接入真实传感器时如何处理抖动
真实传感器数据往往有噪声。可以使用滑动平均、低通滤波或阈值更新策略,避免罗盘文字和指针抖动过于明显。
16.7 UI 层可以如何继续优化
- 为罗盘增加 16 个方向刻度,而不仅是 N/E/S/W。
- 增加校准动画或校准提示弹窗。
- 为方向文本加入中文解释,如
北、东北。 - 根据传感器精度展示不同颜色状态。
- 将
CompassPainter拆分到独立文件,提升可维护性。
十七、完整业务链路复盘
17.1 状态更新链路
- 页面进入
initState()。 _simulateHeading()启动异步循环。- 每 100ms 等待一次。
- 判断
mounted。 _heading增加 0.5 度。% 360保持角度范围。_getDirection(_heading)更新方向文本。setState触发页面重建。- 文本和罗盘同步变化。
17.2 绘制链路
CustomPaint创建CompassPainter。paint获取画布中心和半径。- 绘制外圆。
- 绘制 N/E/S/W 刻度线。
- 使用
TextPainter绘制方向标签。 - 使用
Path绘制北指针。 - 使用
Path绘制南指针。 - 绘制中心点。
heading改变后shouldRepaint返回true。
17.3 数据到 UI 的映射
| 数据 | 处理方式 | UI 输出 |
|---|---|---|
_heading |
toStringAsFixed(1) |
角度文本 |
_heading |
_getDirection |
方向文本 |
_heading |
CompassPainter |
罗盘旋转效果 |
_directions |
角度差判断 | 16 方位名称 |
heading |
shouldRepaint |
控制重绘 |
十八、核心源码总览
下面集中展示当前项目最关键的模拟方向和绘图入口代码。
dart
class _CompassHomePageState extends State<CompassHomePage> {
double _heading = 0;
String _direction = 'N';
@override
void initState() {
super.initState();
_simulateHeading();
}
void _simulateHeading() {
Future.doWhile(() async {
await Future.delayed(const Duration(milliseconds: 100));
if (mounted) {
setState(() {
_heading = (_heading + 0.5) % 360;
_direction = _getDirection(_heading);
});
return true;
}
return false;
});
}
String _getDirection(double heading) {
for (final dir in _directions) {
if ((heading - dir['angle']).abs() < 11.25) {
return dir['name'];
}
}
return 'N';
}
}
CompassPainter 的绘制入口如下:
dart
class CompassPainter extends CustomPainter {
final double heading;
CompassPainter({required this.heading});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 20;
final outerPaint = Paint()
..color = Colors.grey.shade300
..style = PaintingStyle.stroke
..strokeWidth = 4;
canvas.drawCircle(center, radius, outerPaint);
}
@override
bool shouldRepaint(covariant CompassPainter oldDelegate) {
return oldDelegate.heading != heading;
}
}
这两段代码体现了项目主干:状态层模拟角度,绘图层根据角度重绘罗盘。
总结
compass 是一个很适合学习 Flutter 自定义绘图的罗盘 UI Demo。它通过 _heading 保存当前角度,通过 _direction 展示方向文本,通过 _directions 维护 16 方位表,通过 Future.doWhile 模拟角度变化,并用 CustomPainter 完成罗盘图形绘制。
从源码结构看,项目的技术重点集中在三条线:第一条是状态模拟,_simulateHeading() 每 100ms 更新角度;第二条是方向映射,_getDirection() 根据角度匹配 16 方位;第三条是 Canvas 绘图,CompassPainter 使用圆、线、文字、路径和中心点构建完整罗盘。
从 OpenHarmony 适配角度看,当前项目业务逻辑保持在 Flutter 层,ohos 目录负责平台工程承载。它没有读取真实传感器,因此适合作为罗盘 UI 和绘图逻辑的入门案例;如果后续接入真实磁力计或方向传感器,就需要补充权限、平台通道、数据滤波和生命周期解绑等能力。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
- Flutter 官方文档:https://docs.flutter.dev/
- Flutter Widget 目录:https://docs.flutter.dev/ui/widgets
- Flutter 测试文档:https://docs.flutter.dev/testing
- Dart math 库:https://api.dart.dev/stable/dart-math/dart-math-library.html
- OpenHarmony 官网:https://www.openharmony.cn/
- 开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net