HarmonyOS APP开发—"理财通"个人记账理财App,需要用到这个库

理财 App 的数据看板要三种图:支出分类堆叠柱状图、资产走势渐变折线、持仓占比饼图。自己用 Canvas 画要上千行。@ohos/mpchart 几十行搞定——但金融场景对数据准确性零容忍,金额显示 3.1 和 3.10 是两回事,堆叠图数据异常还会直接崩溃。

📦 仓库地址 | ohpm install @ohos/mpchart


场景

"理财通"的数据看板:

  1. 月度支出堆叠柱状图:每根柱子一个月,柱内按"餐饮/交通/购物/居住"分色堆叠——一眼看出哪个月哪类花超了
  2. 资产走势渐变折线图:最近 30 天总资产变化,折线下方渐变填充
  3. 持仓占比饼图:股票/基金/现金/理财的比例分布

金融场景的两个特殊要求:

  • 数据不能错:金额小数位要统一,堆叠数据异常会崩溃
  • 视觉要专业:渐变方向、坐标轴刻度、图例位置,用户一眼看出专不专业

为什么是 mpchart

需求自研 Canvas@ohos/mpchart
堆叠柱状图300+ 行✅ 30 行
渐变折线图400+ 行✅ 30 行
饼图250+ 行✅ 20 行
手势缩放手写✅ 内置
点击高亮 Marker手写✅ 内置
预算警戒线手写✅ LimitLine

关键实现

先定金额格式化(金融场景第一要求)

Y 轴、Marker 气泡、Tooltip 全部用同一个函数,小数位才不会打架:

function formatMoney(value: number, withSymbol = true): string {
  if (!isFinite(value)) return withSymbol ? '¥--' : '--'

  const [intPart, decPart] = Math.abs(value).toFixed(2).split('.')
  const withComma = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',')   // 千分位
  const sign = value < 0 ? '-' : ''

  return `${sign}${withSymbol ? '¥' : ''}${withComma}.${decPart}`
}

// Y 轴空间有限时用简写
function formatMoneyShort(value: number): string {
  return Math.abs(value) >= 10000 ? (value / 10000).toFixed(1) + '万' : value.toFixed(0)
}

console.info(formatMoney(1234567.891))   // ¥1,234,567.89
console.info(formatMoney(-500))          // -¥500.00

支出堆叠柱状图

堆叠涉及多数据集累加计算,数据异常容易触发崩溃,渲染前必须校验:

// ✅ 校验函数:返回错误信息,null 表示通过
function validateStackedData(dataSets: BarDataSet[], expectedCount: number): string | null {
  if (dataSets.length === 0) return '数据集为空'

  for (const set of dataSets) {
    if (set.getEntryCount() !== expectedCount) {
      return `${set.getLabel()} 条目数不一致: ${set.getEntryCount()} vs ${expectedCount}`
    }
    for (let i = 0; i < set.getEntryCount(); i++) {
      const v = set.getEntryForIndex(i).getY()
      if (!isFinite(v)) return `${set.getLabel()} 第 ${i} 项不是有效数字`
      if (v < 0) return `${set.getLabel()} 第 ${i} 项为负数: ${v}`
    }
  }
  return null
}

const CATEGORIES = [
  { key: 'food', name: '餐饮', color: 0xFFFF6B6B },
  { key: 'transport', name: '交通', color: 0xFF4ECDC4 },
  { key: 'shopping', name: '购物', color: 0xFFFFEAA7 },
  { key: 'housing', name: '居住', color: 0xFF6C5CE7 },
]

aboutToAppear() {
  const dataSets = CATEGORIES.map((cat) => {
    const entries = new JArrayList<BarEntry>()
    this.expenseData[cat.key].forEach((amount, i) => entries.add(new BarEntry(i, amount)))
    const set = new BarDataSet(entries, cat.name)
    set.setColorByColor(cat.color)
    return set
  })

  // ✅ 先校验再渲染
  const error = validateStackedData(dataSets, this.months.length)
  if (error) {
    console.error('堆叠数据校验失败: ' + error)
    this.hasError = true
    return
  }

  const barData = new BarData(dataSets)
  barData.setBarWidth(0.6)

  this.model.setData(barData)
  this.model.getXAxis().setPosition(XAxisPosition.BOTTOM)
  this.model.getXAxis().setValueFormatter((v) => this.months[Math.floor(v)] ?? '')
  this.model.getAxisLeft().setValueFormatter((v) => formatMoneyShort(v))   // ✅ 统一格式化
  this.model.getAxisRight().setEnabled(false)
  this.model.getLegend().setEnabled(true)

  // 预算警戒线
  const budgetLine = new LimitLine(20000, '月度预算')
  budgetLine.setLineColor(0xFFFF6B6B)
  budgetLine.setLabelPosition(LimitLabelPosition.RIGHT_TOP)
  this.model.getAxisLeft().addLimitLine(budgetLine)

  this.barChart.setOnChartValueSelectedListener({
    onValueSelected: (e, h) => {
      promptAction.showToast({ message: `${this.months[Math.floor(e.getX())]} ${formatMoney(e.getY())}` })
    },
    onNothingSelected: () => {},
  })

  this.barChart.setModel(this.model)
}

数据异常时显示友好提示,而不是崩溃或空白:

if (this.hasError) {
  Column({ space: 8 }) {
    Text('📊').fontSize(40)
    Text('数据异常,暂无法展示').fontSize(14).fontColor('#999')
  }.layoutWeight(1).justifyContent(FlexAlign.Center)
} else {
  this.barChart
}

资产走势渐变折线图

const dataSet = new LineDataSet(entries, '总资产')
dataSet.setLineWidth(2)
dataSet.setDrawCircles(false)     // 30 个点不画圆圈,视觉更简洁
dataSet.setDrawFilled(true)
dataSet.setFillDrawable(new LinearGradient(0xFF4ECDC4, 0x224ECDC4))   // 高深低浅

// 涨跌着色
const trendColor = assets[assets.length - 1] >= assets[0] ? 0xFF4ECDC4 : 0xFFFF6B6B
dataSet.setColorByColor(trendColor)

model.setPinchZoom(true)          // 双指缩放看细节

渐变验收清单(渐变方向错了整体观感就垮):

检查项预期
渐变方向按 Y 值高低分布,不是简单从图表顶部到底部
高点颜色深色不透明
低点颜色浅色带透明度
数据变化时渐变跟随重新计算
缩放后渐变仍正确
深色模式暗背景下依然清晰

持仓占比饼图

const entries = new JArrayList<PieEntry>()
holdings.forEach((h) => entries.add(new PieEntry(h.value, h.name)))

const dataSet = new PieDataSet(entries, '持仓分布')
dataSet.setColors(holdings.map((h) => h.color))
dataSet.setSliceSpace(2)          // 扇区间隙,视觉更清晰

model.setUsePercentValues(true)   // 显示百分比而非绝对值

一个容易踩的点:出海要切坐标轴语言

社区修过一个多语言问题:坐标轴值在繁体中文和日语环境下都显示简体中文。只切 UI 文案不切轴标签,出海用户立刻能看出来。

import { i18n } from '@kit.LocalizationKit'

const lang = i18n.System.getSystemLanguage()

const MONTHS: Record<string, string[]> = {
  'zh-Hans': ['1月','2月','3月','4月','5月','6月'],
  'zh-Hant': ['1月','2月','3月','4月','5月','6月'],
  'ja': ['1月','2月','3月','4月','5月','6月'],
  'en': ['Jan','Feb','Mar','Apr','May','Jun'],
}
const CURRENCY: Record<string, string> = { 'zh-Hans':'¥', 'zh-Hant':'¥', 'ja':'¥', 'en':'$' }

model.getXAxis().setValueFormatter((v) => (MONTHS[lang] ?? MONTHS['en'])[Math.floor(v)] ?? '')
model.getAxisLeft().setValueFormatter((v) => (CURRENCY[lang] ?? '$') + formatMoneyShort(v))

小结

"理财通"这个场景里,@ohos/mpchart 解决了三件事:三种图表一个库几十行出图、手势缩放和点击高亮内置、LimitLine 直接画预算警戒线。

但金融 App 的专业度体现在细节:金额格式化统一收口、堆叠数据渲染前校验、渐变方向肉眼验收、出海时轴标签跟着切语言。这四条做到了,用户才会觉得这个理财 App 靠谱。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐