做流量监控类应用,首页的设计至关重要。用户打开App第一眼看到的就是首页,所以这个页面需要把最核心的信息直观地呈现出来。今天我们就来聊聊如何用Flutter实现一个功能完善、体验流畅的流量监控首页。
请添加图片描述

整体思路

首页要解决的核心问题是:让用户快速了解今天用了多少流量、当前网络状态如何、套餐还剩多少。围绕这几个需求,我把首页拆分成了几个模块:

  • 顶部标题栏:展示App名称和实时网速
  • 流量使用卡片:今日总流量、WiFi和移动数据分开统计
  • 网络状态卡片:当前连接的网络类型和信号强度
  • 套餐进度卡片:套餐使用百分比和剩余天数
  • 快捷功能入口:日历、排行、提醒、测速四个常用功能

这种模块化的设计思路,不仅让代码结构清晰,后期维护也方便。每个模块独立成一个方法,改动某个模块不会影响其他部分。

页面基础结构

先搭建页面的骨架,用Scaffold作为页面容器:

class HomeView extends GetView<HomeController> {
  const HomeView({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: AppTheme.backgroundColor,
      body: SafeArea(
        child: Obx(() => controller.isLoading.value
            ? const Center(child: CircularProgressIndicator())
            : SingleChildScrollView(
                padding: EdgeInsets.all(16.w),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    _buildHeader(),
                    SizedBox(height: 20.h),
                    _buildUsageCard(),
                    SizedBox(height: 16.h),
                    _buildNetworkCard(),
                    SizedBox(height: 16.h),
                    _buildPlanCard(),
                    SizedBox(height: 16.h),
                    _buildQuickActions(),
                  ],
                ),
              )),
      ),
    );
  }
}

这段代码有几个关键点需要注意:

GetView的使用GetView<HomeController>是GetX提供的便捷基类,它会自动帮我们找到对应的controller实例,不需要手动Get.find()。这样写起来更简洁,也不容易出错。

Obx响应式监听:整个内容区域用Obx包裹,监听isLoading状态。数据加载中显示转圈,加载完成后渲染实际内容。这种写法比用FutureBuilder更灵活,因为后续数据更新时UI也会自动刷新。

SafeArea处理:不同设备的刘海屏、挖孔屏形态各异,SafeArea能自动处理这些安全区域,避免内容被遮挡。

ScreenUtil适配16.w20.h这种写法是flutter_screenutil的语法,会根据屏幕尺寸自动计算实际像素值,实现不同设备上的UI一致性。

顶部标题栏实现

标题栏要展示两个信息:App名称和当前网速。右边放一个按钮,点击跳转到实时监控页面。

Widget _buildHeader() {
  return Row(
    mainAxisAlignment: MainAxisAlignment.spaceBetween,
    children: [
      Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            '流量监控',
            style: TextStyle(
              fontSize: 24.sp,
              fontWeight: FontWeight.bold,
              color: AppTheme.textPrimary,
            ),
          ),
          SizedBox(height: 4.h),
          Obx(() => Text(
            '当前速度: ${controller.formatSpeed(controller.currentSpeed.value)}',
            style: TextStyle(
              fontSize: 14.sp,
              color: AppTheme.textSecondary,
            ),
          )),
        ],
      ),
      IconButton(
        onPressed: () => Get.toNamed(Routes.REALTIME),
        icon: Icon(Icons.speed, size: 28.sp, color: AppTheme.primaryColor),
      ),
    ],
  );
}

这里有个细节:网速文字单独用Obx包裹,而不是整个Column。这样做的好处是,网速变化时只会重建这一个Text组件,而不是整个标题栏。虽然性能差异不大,但养成精细化控制重建范围的习惯是好事。

formatSpeed方法在controller里定义,负责把数字转换成人类可读的格式。比如1256.5会显示成"1.23 MB/s",而不是一长串数字。

流量使用卡片

这是首页最醒目的部分,用渐变背景让它从视觉上突出:

Widget _buildUsageCard() {
  return Container(
    padding: EdgeInsets.all(20.w),
    decoration: BoxDecoration(
      gradient: const LinearGradient(
        colors: [AppTheme.primaryColor, AppTheme.secondaryColor],
      ),
      borderRadius: BorderRadius.circular(16.r),
      boxShadow: [
        BoxShadow(
          color: AppTheme.primaryColor.withOpacity(0.3),
          blurRadius: 15.r,
          offset: Offset(0, 8.h),
        ),
      ],
    ),
    child: Column(
      children: [
        Text('今日流量使用', style: TextStyle(fontSize: 14.sp, color: Colors.white70)),
        SizedBox(height: 10.h),
        Obx(() => Text(
          controller.formatBytes(controller.todayUsage.value),
          style: TextStyle(fontSize: 36.sp, fontWeight: FontWeight.bold, color: Colors.white),
        )),
        SizedBox(height: 20.h),
        // WiFi和移动数据分开显示
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            _buildUsageItem('WiFi', controller.formatBytes(controller.todayWifi.value), Icons.wifi),
            Container(width: 1, height: 40.h, color: Colors.white30),
            _buildUsageItem('移动数据', controller.formatBytes(controller.todayMobile.value), Icons.signal_cellular_alt),
          ],
        ),
      ],
    ),
  );
}

渐变背景的选择:从primaryColorsecondaryColor的渐变,让卡片看起来更有质感。纯色背景容易显得单调,渐变能增加视觉层次。

阴影的处理:阴影颜色用主色调的30%透明度,而不是黑色。这样阴影会带有一点蓝色调,和卡片更协调。offset设置为向下偏移8像素,模拟光源从上方照射的效果。

数字字号的选择:今日总流量用36sp的大字号,这是整个页面最重要的数据,要让用户一眼就能看到。下面WiFi和移动数据的字号小一些,形成主次分明的视觉层次。

WiFi和移动数据的展示项封装成独立方法,避免代码重复:

Widget _buildUsageItem(String label, String value, IconData icon) {
  return Column(
    children: [
      Icon(icon, color: Colors.white, size: 24.sp),
      SizedBox(height: 8.h),
      Text(label, style: TextStyle(fontSize: 12.sp, color: Colors.white70)),
      SizedBox(height: 4.h),
      Text(value, style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold, color: Colors.white)),
    ],
  );
}

这个方法接收三个参数:标签文字、数值、图标。图标放在最上面,因为图标的辨识度比文字高,用户扫一眼就知道这是WiFi还是移动数据。

网络状态卡片

这个卡片展示当前连接的网络信息,点击可以跳转到详情页:

Widget _buildNetworkCard() {
  return GestureDetector(
    onTap: () => Get.toNamed(Routes.NETWORK_STATUS),
    child: Container(
      padding: EdgeInsets.all(16.w),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(12.r),
        boxShadow: [
          BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10.r, offset: Offset(0, 4.h)),
        ],
      ),
      child: Obx(() {
        final network = controller.networkInfo.value;
        return Row(
          children: [
            // 左侧图标容器
            Container(
              width: 50.w,
              height: 50.w,
              decoration: BoxDecoration(
                color: network.type == NetworkType.wifi
                    ? AppTheme.wifiColor.withOpacity(0.1)
                    : AppTheme.mobileColor.withOpacity(0.1),
                borderRadius: BorderRadius.circular(12.r),
              ),
              child: Icon(
                network.type == NetworkType.wifi ? Icons.wifi : Icons.signal_cellular_alt,
                color: network.type == NetworkType.wifi ? AppTheme.wifiColor : AppTheme.mobileColor,
                size: 28.sp,
              ),
            ),
            SizedBox(width: 16.w),
            // 中间文字信息
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(network.name.isNotEmpty ? network.name : '未连接',
                      style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600, color: AppTheme.textPrimary)),
                  SizedBox(height: 4.h),
                  Text('信号${network.signalLevel} · ${network.typeString}',
                      style: TextStyle(fontSize: 13.sp, color: AppTheme.textSecondary)),
                ],
              ),
            ),
            // 右侧箭头
            Icon(Icons.chevron_right, color: AppTheme.textSecondary),
          ],
        );
      }),
    ),
  );
}

颜色区分网络类型:WiFi用绿色(wifiColor),移动数据用橙色(mobileColor)。这种颜色编码让用户不用看文字就能快速分辨当前网络类型。图标背景用10%透明度的对应颜色,既有区分度又不会太抢眼。

右侧箭头的作用chevron_right图标是一个视觉暗示,告诉用户这个卡片可以点击。这是移动端常见的交互模式,用户看到箭头就知道点击会有更多内容。

Expanded的使用:中间的文字区域用Expanded包裹,让它占据剩余空间。这样不管网络名称多长,布局都不会乱。如果名称太长,Text会自动省略。

Controller层的数据管理

页面只负责展示,数据的获取和处理都放在Controller里:

class HomeController extends GetxController {
  final isLoading = false.obs;
  final todayUsage = 0.obs;
  final todayWifi = 0.obs;
  final todayMobile = 0.obs;
  final currentSpeed = 0.0.obs;
  final networkInfo = Rx<NetworkInfo>(NetworkInfo());
  final dataPlan = Rx<DataPlan?>(null);

  
  void onInit() {
    super.onInit();
    loadData();
  }
}

响应式变量声明:所有需要在UI上展示的数据都用.obs声明。GetX会追踪这些变量的变化,当值改变时自动通知UI更新。

onInit生命周期onInit在controller被创建时调用,适合做初始化工作。这里调用loadData()加载数据。如果需要在页面每次显示时刷新数据,可以用onReady或者在View的initState里处理。

数据加载方法的实现:

void loadData() {
  isLoading.value = true;
  // 实际项目中这里会调用API或读取本地数据
  todayUsage.value = 1024 * 1024 * 856; // 856 MB
  todayWifi.value = 1024 * 1024 * 650;  // 650 MB
  todayMobile.value = 1024 * 1024 * 206; // 206 MB
  currentSpeed.value = 1256.5; // KB/s
  
  networkInfo.value = NetworkInfo(
    type: NetworkType.wifi,
    name: 'Home_WiFi_5G',
    signalStrength: 85,
    isConnected: true,
  );
  
  isLoading.value = false;
}

这里用的是模拟数据,实际项目中需要对接系统API获取真实的流量统计。数据单位统一用字节,显示时再转换成KB、MB、GB,这样计算更方便,也不容易出错。

流量格式化方法:

String formatBytes(int bytes) {
  if (bytes < 1024) return '$bytes B';
  if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
  if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
  return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}

这个方法根据数值大小自动选择合适的单位。小于1KB显示B,小于1MB显示KB,小于1GB显示MB,否则显示GBtoStringAsFixed控制小数位数,KB保留1位,MB和GB保留2位,避免显示过长的数字。

主题配置

统一的主题配置让整个App风格一致,也方便后期换肤:

class AppTheme {
  static const Color primaryColor = Color(0xFF2196F3);
  static const Color secondaryColor = Color(0xFF03A9F4);
  static const Color wifiColor = Color(0xFF4CAF50);
  static const Color mobileColor = Color(0xFFFF9800);
  static const Color warningColor = Color(0xFFF44336);
  static const Color backgroundColor = Color(0xFFF5F5F5);
  static const Color textPrimary = Color(0xFF212121);
  static const Color textSecondary = Color(0xFF757575);
}

颜色语义化命名:不要用bluegreen这种命名,而是用primaryColorwifiColor这种语义化的名字。这样改主题色时只需要改这一个文件,不用全局搜索替换。

文字颜色分级textPrimary用于标题和重要文字,textSecondary用于次要信息和说明文字。这种分级让页面有层次感,用户能快速找到重点。

快捷功能入口

首页底部放四个常用功能的快捷入口,方便用户一键直达:

Widget _buildQuickActions() {
  final actions = [
    {'icon': Icons.calendar_today, 'label': '日历', 'route': Routes.DATA_CALENDAR},
    {'icon': Icons.leaderboard, 'label': '排行', 'route': Routes.DATA_RANKING},
    {'icon': Icons.notifications_outlined, 'label': '提醒', 'route': Routes.DATA_ALERT},
    {'icon': Icons.speed, 'label': '测速', 'route': Routes.SPEED_TEST},
  ];

  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Text('快捷功能', style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600, color: AppTheme.textPrimary)),
      SizedBox(height: 12.h),
      Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: actions.map((action) {
          return GestureDetector(
            onTap: () => Get.toNamed(action['route'] as String),
            child: Container(
              width: 75.w,
              padding: EdgeInsets.symmetric(vertical: 16.h),
              decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12.r)),
              child: Column(
                children: [
                  Icon(action['icon'] as IconData, size: 28.sp, color: AppTheme.primaryColor),
                  SizedBox(height: 8.h),
                  Text(action['label'] as String, style: TextStyle(fontSize: 12.sp, color: AppTheme.textSecondary)),
                ],
              ),
            ),
          );
        }).toList(),
      ),
    ],
  );
}

数据驱动UI:用一个Map数组定义所有入口的配置,然后用map方法遍历生成Widget。这种写法比写四个重复的Container简洁多了,后续要加新入口只需要往数组里加一项。

固定宽度的考量:每个入口Container设置了固定宽度75.w,配合MainAxisAlignment.spaceBetween,四个入口会均匀分布在一行。如果用Expanded,入口会随屏幕宽度拉伸,在大屏设备上可能不太好看。

套餐进度卡片

这个卡片用环形进度条展示套餐使用情况,需要引入percent_indicator库:

Widget _buildPlanCard() {
  return GestureDetector(
    onTap: () => Get.toNamed(Routes.DATA_PLAN),
    child: Container(
      padding: EdgeInsets.all(16.w),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(12.r),
        boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10.r, offset: Offset(0, 4.h))],
      ),
      child: Obx(() {
        final plan = controller.dataPlan.value;
        if (plan == null) return Center(child: Text('暂无套餐', style: TextStyle(fontSize: 14.sp)));
        return Row(
          children: [
            CircularPercentIndicator(
              radius: 40.r,
              lineWidth: 8.w,
              percent: plan.usagePercentage / 100,
              center: Text('${plan.usagePercentage.toStringAsFixed(0)}%',
                  style: TextStyle(fontSize: 14.sp, fontWeight: FontWeight.bold)),
              progressColor: plan.isOverThreshold ? AppTheme.warningColor : AppTheme.primaryColor,
              backgroundColor: Colors.grey.shade200,
            ),
            SizedBox(width: 16.w),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(plan.name, style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600)),
                  SizedBox(height: 4.h),
                  Text('已用 ${plan.formattedUsed} / ${plan.formattedTotal}',
                      style: TextStyle(fontSize: 13.sp, color: AppTheme.textSecondary)),
                  SizedBox(height: 2.h),
                  Text('剩余 ${plan.daysRemaining} 天',
                      style: TextStyle(fontSize: 12.sp, color: AppTheme.textSecondary)),
                ],
              ),
            ),
            Icon(Icons.chevron_right, color: AppTheme.textSecondary),
          ],
        );
      }),
    ),
  );
}

进度条颜色变化:当使用量超过阈值(比如80%)时,进度条颜色从蓝色变成红色(warningColor),给用户一个视觉警告。这个阈值可以在套餐设置里让用户自定义。

空状态处理:如果用户还没设置套餐,dataPlan会是null,这时显示"暂无套餐"的提示。实际项目中可以做得更友好,比如加个"点击设置套餐"的引导。

写在最后

首页作为App的门面,既要信息丰富又不能显得杂乱。通过合理的卡片布局、清晰的视觉层次、流畅的交互反馈,可以让用户快速获取想要的信息。

这个首页还有很多可以优化的地方:

  • 加载状态可以用骨架屏代替转圈,体验更好
  • 网速可以做成实时刷新,比如每秒更新一次
  • 套餐快到期时可以在卡片上加个醒目的标签
  • 下拉刷新功能,让用户手动更新数据

这些优化点就留给大家自己探索了,动手实践才是最好的学习方式。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐