Flutter三方库适配OpenHarmony【compass】罗盘 UI 项目完整实战

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 真机传感器接入时需要关注的能力边界。

本文重点回答三个问题:

  1. compass 如何用 _heading_direction 表达罗盘状态。
  2. Flutter 如何使用 CustomPainterCanvasPaintPathTextPainter 绘制罗盘。
  3. 这个项目适配 OpenHarmony 时,如何区分 UI 模拟能力和真实传感器能力。

图示说明:本文聚焦 Flutter 罗盘 UI Demo 的状态模拟、方向映射和自定义绘制,核心源码位于 lib/main.dart


一、背景与目标

1.1 项目功能概览

compass 当前是一个罗盘视觉模拟项目。页面顶部展示当前角度和方向文本,中间使用 CustomPaint 绘制罗盘圆盘、方向刻度、N/E/S/W 标签、南北指针和中心点。

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

  • 展示当前角度 _heading
  • 展示当前方向 _direction
  • 维护 16 个方位点:NNNENEENEE 等。
  • 每 100ms 模拟方向增加 0.5 度。
  • 使用 Future.doWhile 构造持续刷新循环。
  • 使用 mounted 防止页面销毁后继续 setState
  • 使用 CustomPaint 承载罗盘绘制。
  • 使用 Canvas.drawCircle 绘制外圆和中心点。
  • 使用 Canvas.drawLine 绘制方向刻度。
  • 使用 TextPainter 绘制 N/E/S/W 标签。
  • 使用 Path 绘制红色北指针和灰色南指针。
  • 使用 shouldRepaint 根据 heading 变化决定重绘。

1.2 技术关键词

关键词 在项目中的作用
dart:math 提供 sincospi 等三角函数能力
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.pimath.cosmath.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'),
    );
  }
}

这里有三个关键信息:

  1. 应用标题是 Compass
  2. 主题种子色是 Colors.brown,比较贴合工具类罗盘的视觉氛围。
  3. 首页是 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 当前方向文本,如 NNES

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 传感器能力边界

当前项目没有读取真实传感器,因此无需定位、磁力计或方向传感器权限。后续若接入真实罗盘能力,需要考虑:

  1. OpenHarmony 侧传感器权限和能力声明。
  2. Flutter 与平台侧的数据通道。
  3. 传感器回调频率和 UI 刷新频率。
  4. 方向角度的滤波、抖动处理和校准提示。
  5. 页面销毁时取消传感器监听。

十五、测试与验证

15.1 页面验证路径

罗盘 Demo 的核心验证路径如下:

  1. 启动应用,标题显示 Compass
  2. 页面显示角度文本,例如 0.0°
  3. 页面显示方向文本,例如 N
  4. 等待 100ms 以上,角度开始变化。
  5. 角度变化后方向文本随之更新。
  6. 罗盘圆盘持续重绘。
  7. N/E/S/W 标签可见。
  8. 红色北指针、灰色南指针和中心点可见。
  9. 页面底部显示 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 状态更新链路

  1. 页面进入 initState()
  2. _simulateHeading() 启动异步循环。
  3. 每 100ms 等待一次。
  4. 判断 mounted
  5. _heading 增加 0.5 度。
  6. % 360 保持角度范围。
  7. _getDirection(_heading) 更新方向文本。
  8. setState 触发页面重建。
  9. 文本和罗盘同步变化。

17.2 绘制链路

  1. CustomPaint 创建 CompassPainter
  2. paint 获取画布中心和半径。
  3. 绘制外圆。
  4. 绘制 N/E/S/W 刻度线。
  5. 使用 TextPainter 绘制方向标签。
  6. 使用 Path 绘制北指针。
  7. 使用 Path 绘制南指针。
  8. 绘制中心点。
  9. 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 和绘图逻辑的入门案例;如果后续接入真实磁力计或方向传感器,就需要补充权限、平台通道、数据滤波和生命周期解绑等能力。

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


相关资源:

相关推荐
ZC跨境爬虫2 小时前
跟着 MDN 学CSS day_40:(Flexbox实战技能测试)
前端·css·ui·html·tensorflow
ZC跨境爬虫2 小时前
跟着 MDN 学CSS day_36:(float、clear与BFC深度解析)
前端·javascript·css·ui·交互
G_dou_3 小时前
Flutter +OpenHarmony 实战:clock 时钟应用
flutter·harmonyos
G_dou_3 小时前
Flutter+OpenHarmony 实战:weather 天气查询应用
flutter·harmonyos
韩曙亮4 小时前
【错误记录】flutter pub get 执行报错 ( 打开 Windows 开发者模式 )
windows·flutter
豆豆4 小时前
2026实测:AI生成UI设计稿后,如何优雅集成到PageAdmin CMS?(附标签替换代码)
人工智能·ui·cms·建站系统·ai工具·ai建站
G_dou_5 小时前
Flutter+OpenHarmony实战:flashlight】手电筒项目
flutter·harmonyos
爱吃大芒果6 小时前
鸿蒙 ArkUI 架构蓝图:MoodLite 的 UI 渲染与数据逻辑解耦实践
ui·架构·harmonyos