手把手带你用Flutter手搓人生K线
本文基于 Flutter 框架,从 Canvas 绘制、K 线数据结构、蜡烛图核心绘制逻辑、MA 指标实现,到手势冲突优化,全方位拆解金融 APP K 线图开发流程,分享实战问题与解决方案,助力开发者快速实现流畅可落地的 K 线组件。
在金融类 APP 开发中,K 线图是必不可少的组件之一,体验直接可导致用户数量的流失
本文将通过 Flutter 框架,并结合实际的开发经验,从 Canvas 绘制基础、数据结构定义、核心绘制逻辑、技术指标实现到手势系统优化,全方位的拆解 K 线图的开发过程,分享我开发过程中遇到的问题以及解决方案,帮助你掌握 Flutter K 线图开发技巧
先看最终效果
一、Canvas 绘制基础
首先我们得先学习 Flutter 中的 Canvas 绘制
懂 Canvas 绘制基础可直接跳过这条段,想要在 Flutter 中自定义绘制,核心需要通过 CustomPaint + CustomPainter
在动手之前需要先把 Flutter Canvas 坐标系规规则给理解一下
- 原点 (0,0) 在绘制区域的左上角
- x 轴向右为正
- y 轴向下为正
与我们日常认知的“y轴向上为正”不同,需要记住这一点,这是避免绘制错位的关键
简单 Demo
为快速熟悉Canvas的使用方式,我们先实现一个简单的Demo,绘制一个填充圆形和一根线条,掌握Paint配置、坐标计算及Canvas绘制方法:
import 'package:flutter/material.dart';
class CanvasApp extends StatefulWidget {
const CanvasApp({super.key});
@override
State<CanvasApp> createState() => _CanvasAppState();
}
class _CanvasAppState extends State<CanvasApp> {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
child: CustomPaint(painter: DemoPainter()),
);
}
}
class DemoPainter extends CustomPainter {
final fill = Paint()
..style = PaintingStyle.fill
..color = Colors.blue;
final stroke = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 6
..color = Colors.black;
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
canvas.drawCircle(center, 60, fill); // 绘制填充圆形
canvas.drawLine(center, center + Offset(80, -40), stroke); // 绘制线条
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
上面的 Demo中,通过Paint配置绘制样式(填充/描边、颜色、线宽)
在paint方法中通过Canvas的drawCircle、drawLine方法完成绘制
shouldRepaint 返回false表示不重复绘制,提升性能
二、K线图数据结构定义
接下来我们就需要先了解 K 线数据接口的定义,进入 K 线的开发
首先需定义规范的数据结构,存储单根K线的核心信息
一根完整的K线包含开盘价、最高价、最低价、收盘价、成交量和时间戳六大核心字段,对应的数据结构如下:
class CandleEntity {
double open; // 开盘价
double high; // 最高价
double low; // 最低价
double close; // 收盘价
double vol; // 成交量
int? time; // 时间戳(毫秒)
}
CandleEntity 类是K线图开发的“数据载体”,后面所有绘制逻辑(蜡烛、均线)均围绕该类的实例展开
实际开发中,也可以根据需求扩展字段,比如添加均线值列表(maValueList),用于存储单根K线对应的各类均线数据
三、单根K线绘制逻辑
K线图的核心是 Candle 的绘制,单根 Candle 由实体部分(开盘价与收盘价之间的矩形)和影线部分(最高价与最低价之间的线段)组成,而且需区分阳线(涨)和阴线(跌),绘制逻辑如下
价格与屏幕坐标映射
因为Canvas坐标系和实际价格维度不一样,所以得把价格转换成屏幕上的Y坐标。核心逻辑就是用当前K线数据集的最高价、最低价算缩放比例,再把价格映射成屏幕坐标,公式如下:
double getY(double y) => (maxValue - y) * scaleY + _contentRect.top;
- maxValue 为当前K线数据集的最高价
- scaleY 为价格维度的缩放比例
- _contentRect.top 为绘制区域的顶部坐标
通过这个公式可确保价格越高,对应的屏幕Y坐标越小
单根蜡烛绘制逻辑
单根蜡烛的绘制需处理三个核心细节:阳线与阴线的颜色区分、实体部分的最小高度(避免十字星看不见)、动态影线宽度(根据缩放级别调整,提升视觉体验)
完整代码如下:
/// 绘制单根蜡烛图
/// [curPoint] 当前 K 线数据
/// [canvas] 画布
/// [curX] 当前 K 线的 X 坐标(中心点)
void drawCandle(CandleEntity curPoint, Canvas canvas, double curX) {
// 将价格转换为屏幕 Y 坐标
var high = getY(curPoint.high); // 最高价对应的 Y 坐标
var low = getY(curPoint.low); // 最低价对应的 Y 坐标
var open = getY(curPoint.open); // 开盘价对应的 Y 坐标
var close = getY(curPoint.close); // 收盘价对应的 Y 坐标
double r = mCandleWidth / 2; // 实体半宽
// 动态影线宽度计算:根据缩放级别平滑调整影线宽度,缩放越小影线越粗
double lineR = _calculateDynamicShadowWidth() / 2; // 影线半宽
// 阳线(涨):开盘价 >= 收盘价
if (open >= close) {
// 确保实体有最小可见高度(避免十字星看不见)
if (open - close < mCandleLineWidth) {
open = close + mCandleLineWidth;
}
chartPaint.color = this.chartColors.upColor; // 阳线颜色(如红色)
// 绘制实体矩形(从收盘价到开盘价)
canvas.drawRect(
Rect.fromLTRB(curX - r, close, curX + r, open), chartPaint);
// 绘制上下影线(从最高价到最低价)
canvas.drawRect(
Rect.fromLTRB(curX - lineR, high, curX + lineR, low), chartPaint);
}
// 阴线(跌):收盘价 > 开盘价
else if (close > open) {
// 确保实体有最小可见高度
if (close - open < mCandleLineWidth) {
open = close - mCandleLineWidth;
}
chartPaint.color = this.chartColors.dnColor; // 阴线颜色(如绿色)
// 绘制实体矩形(从开盘价到收盘价)
canvas.drawRect(
Rect.fromLTRB(curX - r, open, curX + r, close), chartPaint);
// 绘制上下影线
canvas.drawRect(
Rect.fromLTRB(curX - lineR, high, curX + lineR, low), chartPaint);
}
}
上面的代码中,通过判断开盘价与收盘价的大小区分阳阴线,动态调整实体高度和影线宽度,确保在不同缩放级别下,K线都能清晰显示,提升用户体验
四、技术指标实现
K线图除了蜡烛本身,还需展示各类技术指标,其中移动平均线(MA)是最常用的指标之一
MA的实现核心是滑动窗口算法,通过维护固定周期的收盘价累加和,计算每个周期的均值,时间复杂度为O(n)
MA均线计算逻辑
/// 计算移动平均线(Moving Average)
/// [dataList] K 线数据列表
/// [maDayList] 均线周期列表,例如 [5, 10, 20] 表示计算 MA5、MA10、MA20
static calcMA(List<KLineEntity> dataList, List<int> maDayList) {
// ma[i] 保存第 i 个周期的累加和
List<double> ma = List<double>.filled(maDayList.length, 0);
if (dataList.isNotEmpty) {
for (int i = 0; i < dataList.length; i++) {
KLineEntity entity = dataList[i];
final closePrice = entity.close;
// 为每个 K 线创建 MA 值列表
entity.maValueList = List<double>.filled(maDayList.length, 0);
// 计算每个周期的 MA 值
for (int j = 0; j < maDayList.length; j++) {
ma[j] += closePrice; // 累加当前收盘价
// 达到周期时开始计算均值
if (i == maDayList[j] - 1) {
entity.maValueList?[j] = ma[j] / maDayList[j];
}
// 滑动窗口:减去最早的值,保持窗口大小
else if (i >= maDayList[j]) {
ma[j] -= dataList[i - maDayList[j]].close;
entity.maValueList?[j] = ma[j] / maDayList[j];
}
}
}
}
}
上面即是实现 MA均线计算的逻辑,通过双重循环实现多周期MA计算:外层循环遍历所有K线数据,内层循环针对每个均线周期,累加收盘价,当达到周期长度时计算均值,后续通过滑动窗口更新均值(减去滑出窗口的收盘价,加上新的收盘价),确保计算高效
MA均线绘制逻辑
当完成逻辑的计算之后,通过绘制线段实现绘制,核心是获取相邻两根K线的MA值对应的屏幕坐标,调用drawLine方法完成绘制
void drawMaLine(CandleEntity lastPoint, CandleEntity curPoint, Canvas canvas,
double lastX, double curX) {
// 获取均线线条宽度
final lineWidth = _calculateMainIndicatorWidth();
for (int i = 0; i < (curPoint.maValueList?.length ?? 0); i++) {
if (i == 3) break; // 控制均线显示数量(如只显示前3条)
if (lastPoint.maValueList?[i] != 0) {
// 绘制相邻两根K线的MA线段,区分不同均线颜色
drawLine(lastPoint.maValueList?[i], curPoint.maValueList?[i], canvas,
lastX, curX, this.chartColors.getMAColor(i),
lineWidth: lineWidth);
}
}
}
五、手势系统
交互体验在 K 线图中是非常重要的,必须要支持缩放、拖拽、点击、长按这四个核心的手势
但是 Flutter 的手势系统有一个手势竞技场(Gesture Arena)的机制,导致有手势冲突的问题
下面我提供了解决方案
手势冲突解决方案
问题描述:如果同时用了 HorizontalDrag 拖拽 和 ScaleGesture 缩放,这两个手势会互相抢焦点,导致双指缩放时,水平滑动会被拖拽抢走,缩放就断了,有种卡顿的感觉
解决办法很简单:
用 Listener 组件处理先判断有几根手指在屏幕上,再自动切换是拖拽还是缩放,互不干扰:
- 一根手指(_pointerCount < 2):只走拖拽逻辑,让 K 线图左右滑动,看更早的数据
- 两根及以上手指(_pointerCount ≥ 2):只走缩放逻辑,让 K 线图放大缩小,看细节或看整体
缩放 + 拖拽
Listener(
onPointerDown: (_) => setState(() => _pointerCount++),
onPointerUp: (_) => setState(() => _pointerCount--),
onPointerCancel: (_) => setState(() => _pointerCount--),
child: RawGestureDetector(
scaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<ScaleGestureRecognizer>(
() => ScaleGestureRecognizer(),
(instance) {
instance
..onStart = (details) {
// 保存基线值,用于缩放计算
_scaleBase = 1.0;
_scaleXBase = mScaleX;
// 计算缩放锚点(焦点对应的 K 线索引)
_anchorIndex = painter.calculateSelectedX(details.focalPoint.dx);
}
..onUpdate = (details) {
// 检测手指数量变化,重置基线
if (_pointerCount != _lastPointerCount) {
_scaleBase = details.scale;
_scaleXBase = mScaleX;
_anchorIndex = painter.calculateSelectedX(details.focalPoint.dx);
}
if (_pointerCount < 2) {
// 单指:拖拽,调整滑动偏移量
final delta = details.focalPointDelta.dx / mScaleX;
mScrollX = (mScrollX + delta).clamp(0.0, maxScrollX);
} else {
// 双指:缩放,控制缩放范围(0.2~4.0)
final relativeScale = details.scale / _scaleBase;
mScaleX = (_scaleXBase * relativeScale).clamp(0.2, 4.0);
// 焦点锚定:保持缩放中心不动,提升体验
}
}
..onEnd = (details) {
// 单指拖拽结束:启动惯性滚动
if (_pointerCount == 0 && _lastPointerCount == 1) {
_onFling(details.velocity.pixelsPerSecond.dx);
}
};
},
),
// 长按、点击手势配置
longPressGestureRecognizer: ...,
tapGestureRecognizer: ...
),
);
其它手势实现
(1)点击手势
点击手势点击主要做两件事:切换十字线显示、画趋势线,通过 TapGestureRecognizer 实现
- 普通模式:点一下 K 线图,十字线就显示 / 隐藏,同时会显示这根 K 线的详细数据,比如开盘价、收盘价
- 趋势线模式:点两下,第一下记起点,第二下记终点,就能画出趋势线
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(),
(TapGestureRecognizer instance) {
instance.onTapUp = (details) {
// 普通点击模式:切换十字线显示状态
if (!widget.isTrendLine &&
painter.isInMainRect(details.localPosition)) {
if (_isCrossLocked) {
// 十字线已显示,点击则隐藏
_isCrossLocked = false;
isOnTap = false;
mInfoWindowStream.sink.add(null); // 清空信息弹窗
} else {
// 十字线未显示,点击则显示并锁定
_isCrossLocked = true;
isOnTap = true;
mSelectX = details.localPosition.dx;
}
notifyChanged();
}
// 趋势线模式:记录点击的坐标点
if (widget.isTrendLine && !isLongPress && enableCordRecord) {
enableCordRecord = false;
Offset p1 = Offset(getTrendLineX(), mSelectY);
// 第一次点击:创建趋势线的起点
if (!waitingForOtherPairofCords) {
lines.add(TrendLine(
p1, Offset(-1, -1), trendLineMax!, trendLineScale!));
}
// 第二次点击:完成趋势线的终点
if (waitingForOtherPairofCords) {
var a = lines.last;
lines.removeLast();
lines.add(
TrendLine(a.p1, p1, trendLineMax!, trendLineScale!));
waitingForOtherPairofCords = false;
} else {
waitingForOtherPairofCords = true;
}
notifyChanged();
}
};
},
),
(2)长按手势
长按手势长按用来移动十字线、调整趋势线,通过 LongPressGestureRecognizer 实习那
- 普通模式:长按屏幕并移动手指,十字线会跟着手指走,实时显示指到哪里的 K 线信息
- 趋势线模式:长按画好的趋势线,就能拖动调整位置,方便修改
LongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<
LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(),
(LongPressGestureRecognizer instance) {
instance
// 长按开始
..onLongPressStart = (details) {
isOnTap = false;
isLongPress = true;
// 普通模式:记录十字线位置
if ((mSelectX != details.localPosition.dx ||
mSelectY != details.globalPosition.dy) &&
!widget.isTrendLine) {
mSelectX = details.localPosition.dx;
notifyChanged();
}
// 趋势线模式:初始化位置记录
if (widget.isTrendLine && changeinXposition == null) {
mSelectX = changeinXposition = details.localPosition.dx;
mSelectY = changeinYposition = details.globalPosition.dy;
notifyChanged();
}
if (widget.isTrendLine && changeinXposition != null) {
changeinXposition = details.localPosition.dx;
changeinYposition = details.globalPosition.dy;
notifyChanged();
}
}
// 长按移动 - 更新十字线位置
..onLongPressMoveUpdate = (details) {
// 普通模式:跟随手指移动十字线
if ((mSelectX != details.localPosition.dx ||
mSelectY != details.globalPosition.dy) &&
!widget.isTrendLine) {
mSelectX = details.localPosition.dx;
mSelectY = details.localPosition.dy;
notifyChanged();
}
// 趋势线模式:移动趋势线
if (widget.isTrendLine) {
// 计算相对移动距离
mSelectX = mSelectX +
(details.localPosition.dx - changeinXposition!);
changeinXposition = details.localPosition.dx;
mSelectY = mSelectY +
(details.globalPosition.dy - changeinYposition!);
changeinYposition = details.globalPosition.dy;
notifyChanged();
}
}
// 长按结束
..onLongPressEnd = (details) {
isLongPress = false;
enableCordRecord = true; // 启用趋势线坐标记录
// 长按结束后锁定十字线,保持显示
if (!widget.isTrendLine) {
_isCrossLocked = true;
isOnTap = true; // 保持 isOnTap 为 true 以显示十字线
} else {
mInfoWindowStream.sink.add(null); // 趋势线模式清空信息弹窗
}
notifyChanged();
};
},
),
double getY(double y) => (maxValue - y) * scaleY + _contentRect.top;
六、总结
最费时间就是缩放和拖拽的冲突问题
后面借鉴 Interactive Chart 这个开源项目的实现思路,用“Listener + ScaleGesture”实现水平移动和缩放,解决了这个问题,缩放和拖拽都丝滑不卡顿
总结一下,本篇文章主要讲解 Canvas 绘制、坐标映射、K 线图绘制基础,并解决了手势冲突的问题
其实K线图开发看着复杂,只要把绘制、数据处理、手势这几个核心模块拆解开,逐一突破,就能轻松搞定,做出高效、流畅、能落地的组件
本文的思路和代码,大家可以直接用到实际项目里,也能根据业务需求扩展功能(比如MACD、RSI指标、成交量显示、行情标注等),希望能帮到正在做Flutter K线图的小伙伴,少走弯路、快速落地!
参考:
团队招募 | 共同探索技术边界
AI 时代已经到来,当下最好的破局机会,就是加入一家有潜力的 AI 公司
比特鹰致力于将每位成员,打造成 AI 时代的超级个体,在为用户创造价值的同时实现人生梦想
以下岗位持续开放中:
- 后端开发工程师
- 前端开发工程师
- AI 应用开发工程师
- 爬虫工程师
- 大数据开发工程师
- HR 人事
如果您想在 AI 时代实现百倍的个人提升,欢迎加入我们
联系方式:join@biteagle.xyz