Flutter For Web——一个简单的图片素材网站

news2024/11/19 19:23:49

一个简单的图片素材网站

  • 效果视频
  • 登录注册页
    • 效果图
    • UI
      • 初始化
      • TabBar
      • PageView
      • 组合
      • 登录
        • 账号输入
        • 按键处理
      • SharedPreferences封装
        • 保存数据
        • 取出数据
        • 清除缓冲内容
  • 搜索栏
    • 效果图
    • UI
  • 首页
    • 效果图
    • UI
  • Dio网络请求
    • Dio单例封装
    • 构造Dio对象
    • Get
    • Post
    • Response
    • 使用
    • 解析Json
  • 图片阅览
    • UI
    • Dialog
  • 下载
    • UI
    • 调用浏览器进行下载
  • Git

效果视频

一个简单图片素材网站

登录注册页

效果图

UI

登录和注册页滑动切换使用的是TabBar+PageView完成

初始化

首先初始化TabBar和PageView控制器,并为其添加切换监听事件

 late final  _pageController;
  late final  _tabController;
  final List<String> _tabs = <String>['登录','注册'];

 @override
  void initState() {
    _pageController = PageController();
   _tabController = TabController(length: _tabs.length, vsync: this);
    super.initState();
  }


  void _changeTab(int index) {
    _pageController.animateToPage(index, duration: const Duration(milliseconds: 300), curve: Curves.ease);
  }

  void _onPageChanged(int index) {
    _tabController.animateTo(index, duration: const Duration(milliseconds: 300));
  }

  @override
  void dispose() {
    _pageController.dispose();
    _tabController.dispose();
    super.dispose();
  }

TabBar

TabBar的使用方法如下,重点就是点击事件和控制器的绑定

Widget navBar = TabBar(
      //选中的颜色
      labelColor: Colors.white,
      labelStyle: const TextStyle(color: Colors.white, fontSize: 16),
      //未选中的颜色
      unselectedLabelColor: Colors.black,
      unselectedLabelStyle: const TextStyle(color: Colors.black, fontSize: 16),
      //去掉下划线
      indicator: const BoxDecoration(),
      controller: _tabController,
      onTap: _changeTab,
      tabs: _tabs.map((e) => Tab(text: e)).toList(),
    );

PageView

PageView的使用方法如下,重点就是页面切换事件和控制器的绑定,它的子组件就由需要滑动的页面组成,这里这样登录和注册两个

Widget navViews = SizedBox(
      width: 500.0,
      height: 320.0,
      child: PageView(
        controller: _pageController,
        onPageChanged: _onPageChanged,
        children: [
           ConstrainedBox(
            constraints: const BoxConstraints.expand(),
            child: const LoginPage()
          ),
          ConstrainedBox(
              constraints: const BoxConstraints.expand(),
              child: const RegisterPage()
          )
        ],
      ),
    );

组合

最外层插背景图片,并铺满全屏,下面就控制登录、注册界面在屏幕左方

return Scaffold(
      body: Container(
            decoration: bg,
            width:  double.infinity,
            height: double.infinity,
            child:Align(
              alignment: Alignment.centerLeft,
              child: Wrap(
                  children:[
                    Container(
                      margin: const EdgeInsets.only(left: 100.0),
                      child: Column(
                        children: [
                          Container(
                            width: 200.0,
                            decoration: gradient,
                            child: navBar,
                          ),
                          const SizedBox(height: 10.0),
                          navViews
                        ],
                      ),
                    )
                  ]
              ),
        ),
    )
    );

登录

登录与注册一致,此处以登录为例子,一个简单的表单和缓存记录比对,通过shared_preferences这个库对注册数据进行缓存,然后登录进行读取,从而进行判断

账号输入

账号和密码差不多,以账号为例子;同样绑定控制器和焦点节点,在尾部添加一个清空文本按钮,当内容不为空时出现,反之,隐藏;validator里面为不满足你所设置的条件,则下方弹出一行提示(内容自定义),基本Material风格都这样设计的

///用户名
    Widget username_input = TextFormField(
      maxLines: 1,
      controller: _usernameController,
      focusNode: _focusNodeUserName,
      decoration: InputDecoration(
        icon:const Icon(Icons.people_alt_outlined),
        labelText: '账号',
          suffixIcon: (_isShowClear)
              ? IconButton(
              icon: const Icon(Icons.clear),
              onPressed: () {
                // 清空文本框的内容
                _usernameController.clear();
              })
              : null),
      validator: (value) {
        if(value == null || value.isEmpty){
          return '用户名不能为空';
        }else{
          return null;
        }
      },
      onSaved: (String? data) {
        _username = data.toString();
      },
      autovalidateMode: AutovalidateMode.onUserInteraction,
    );

按键处理

重点在于点击事件那里,可以加一个表单验证,就是加入你输入的内容为空时,不满足上述validator所设置的条件,就可以不执行方法体的内容,因为dart判空机制,所以前面需要加一个

 ///登录按钮
    Widget loginButton = Container(
        width: 150.0,
        height: 40.0,
        decoration: gradient,
        child:ElevatedButton(
            style: ButtonStyle(
              //去除阴影
              elevation: MaterialStateProperty.all(0),
              //将按钮背景设置为透明
              backgroundColor: MaterialStateProperty.all(Colors.transparent),
            ),
            onPressed: () {
              if (_formKey.currentState!.validate()) {
                _formKey.currentState!.save();
               login(_username, _password,context);
                //testDio();
              }
            },
            child: const Text('登录')
        )
    );

通过按钮被按下后,通过键值对获取SharedPreferences的缓存内容,然后与输入的进行判断,并通过Toast进行提示

void login(String username,String password,BuildContext context) async{
  String? _username = await SpUtil.getValue<String>('username');
  String? _password = await SpUtil.getValue<String>('password');

  if (_username == username && _password == password) {
    print('[成功信息]:登录成功');
    showSuccessToast('登录成功!');
    Navigator.of(context).push(MaterialPageRoute(builder: (context) => const HomePage()));
  } else {
    print('[错误信息]:登录失败');
    showFailedToast('登录失败!');
  }
}

SharedPreferences封装

首先导入依赖

shared_preferences: ^2.0.15

保存数据

  static setValue<T>(String key, T value) async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    switch (T) {
      case String:
        prefs.setString(key, value as String);
        break;
      case int:
        prefs.setInt(key, value as int);
        break;
      case bool:
        prefs.setBool(key, value as bool);
        break;
      case double:
        prefs.setDouble(key, value as double);
        break;
    }
  }

取出数据

  static Future<T> getValue<T>(String key) async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    late T res;
    switch (T) {
      case String:
        res = prefs.getString(key) as T;
        break;
      case int:
        res = prefs.getInt(key) as T;
        break;
      case bool:
        res = prefs.getBool(key) as T;
        break;
      case double:
        res = prefs.getDouble(key) as T;
        break;
    }
    return res;
  }

清除缓冲内容

static void removeCache(String key) async{
    SharedPreferences sp = await SharedPreferences.getInstance();
    sp.remove(key);
  }

  static void removeAllCache() async{
    SharedPreferences sp = await SharedPreferences.getInstance();
    sp.clear();
  }

搜索栏

因为我没有找到好的图片素材接口,那个接口没有通过关机键搜索然后返回内容的接口,所以此处搜索栏没有使用通过搜索内容跳转相关内容的页面,无论输入什么都跳转至全览页

效果图

UI

一个背景图加一个Colum布局
搜索条通过InputDecoration包含了一个尾部跳转按钮,border: InputBorder.none此句可以去除输入框下划线

var inputStyle = InputDecoration(
        suffixIcon: IconButton(
            onPressed: (){
              Navigator.of(context).push(MaterialPageRoute(builder: (context) => const AllImage()));
            },
            icon: const Icon(Icons.g_mobiledata_outlined)),
        icon:const Icon(Icons.search),
        hintText: 'Search for all image',
        border: InputBorder.none);

然后使用Card布局,在添加一点间距,圆角角度加大一点,就完成了一个搜索条

/// 搜索框
    Widget searchBar = Container(
      height: 60.0,
      width: 800.0,
      //padding: const EdgeInsets.all(20.0),
        child: Card(
          shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.all(Radius.circular(30.0))),
          color: Colors.white,
          child:  Container(
              alignment: Alignment.center,
              margin: const EdgeInsets.only(left: 20.0),
              child:TextField(decoration: inputStyle,maxLines: 1,))
      )
    );

上图效果最后由如下代码组装

 Widget topList = Column(
      mainAxisAlignment: MainAxisAlignment.center,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(first_line_text,style: getTextStyle(28.0, FontWeight.bold, Colors.white)),
        const SizedBox(height: 20.0,),
        Text(second_line_text,style: getTextStyle(14.0, FontWeight.bold, Colors.white)),
        const SizedBox(height: 20.0,),
        searchBar,
        const SizedBox(height: 20.0,),
        Text(third_line_text,style: getTextStyle(14.0, FontWeight.bold, Colors.white))
      ],
    );

首页

效果图

UI

此处只是简单使用GridView进行图片内容展示

  Widget bottomArea = Container(
      height: 750,
      margin: const EdgeInsets.only(left: 100.0,right: 100.0),
      child: GridView.count(
        physics: const NeverScrollableScrollPhysics(),
        crossAxisCount: 6,
        mainAxisSpacing: 20.0,
        crossAxisSpacing: 20.0,
        childAspectRatio: 0.7,
        children: List.generate(imageList.length, (index) => getImageChile(imageList[index],context)),
      ),
    );

将获取的网络图片通过Image.network进行展示,并设置未显示时,显示loading样式的progress占位,并使用GestureDetector为图片添加点击事件

GestureDetector getImageChile(ImageBeanEntity entity,BuildContext context){
    return GestureDetector(
      onTap: (){
        DialogUtil.showImageDialog(context, entity.img);
      },
      child: Image.network(
          entity.img,
          errorBuilder: (context,error,stackTrace){
            return const CircularProgressIndicator();
          },
          loadingBuilder: (context,child,progress){
            if(progress == null)return child;
            return Container(
              alignment: Alignment.center,
              child: CircularProgressIndicator(
                value: progress.expectedTotalBytes != null ?
                progress.cumulativeBytesLoaded / progress.expectedTotalBytes! : null,
              ),
            );
          }
      ),
    );
  }

Dio网络请求

本例通过Dio库进行网络请求访问,添加如下依赖

dio: ^4.0.0

Dio单例封装

通过懒汉单例构造Dio封装

  static var dio;
  static var dioUtils;

  static DioUtils get instance => getInstance();

  static DioUtils getInstance() {
    return dioUtils ??= DioUtils();
  }

构造Dio对象

其中baseUrl为接口前缀,例如http://172.0.0.1/?limit=12这个示例接口,其中http://172.0.0.1就为baseUrl,此处只做为示例,具体根据开发需求和自己喜好,也可动态配置

  // 创建 dio 实例对象
  static Dio createInstance() {
    if (dio == null) {
      /// 全局属性:请求前缀、连接超时时间、响应超时时间
      var options = BaseOptions(
        // responseType: ResponseType.json,
        baseUrl: ApiPath.baseUrl,
        connectTimeout: _connectTimeout,
        receiveTimeout: _receiveTimeout,
        sendTimeout: _sendTimeout,
      );
      dio = Dio(options);
    }
    return dio;
  }

  // 清空 dio 对象
  static clear() {
    dio = null;
  }

Get

通过传入接口和参数,然后将请求结果通过回调函数进行回调,此处指定为Get方式请求

get<T>(String url, FormData? param, Function(T t) onSuccess, Function(String error) onError) async {
    requestHttp<T>(
      url,
      param: param,
      method: GET,
      onSuccess: onSuccess,
      onError: onError,
    );
  }

Post

与Get方法一样,此处不在阐述

post<T>(String url, FormData param, Function(T t) onSuccess, Function(String error) onError) async {
    requestHttp<T>(
      url,
      param: param,
      method: POST,
      onSuccess: onSuccess,
      onError: onError,
    );
  }

Response

此处建立dio对象,然后进行网络请求,最后将response.dataJson字符串进行回调,若是失败则走失败回调

 static requestHttp<T>(String url, {param, method, required Function(T map) onSuccess, required Function(String error) onError,}) async {
    dio = createInstance();
    try {
      Response response = await dio.request(
        url,
        data: param,
        options: Options(method: method));
      if (response.statusCode == 200) {
        onSuccess(response.data);
      } else {
        onError("【statusCode】${response.statusCode}");
      }
    } on DioError catch (e) {
      /// 打印请求失败相关信息
      print("【请求出错1】${e.toString()}");
      onError(e.toString());
    }
  }

使用

此处处理网络请求返回回来的数据

void getImageData(Function(List<ImageBeanEntity> t) onSuccess, Function(String error) onError){
  DioUtils.instance.get(ApiPath.verticalUrl, null, (data){
    var baseBean = BaseImageEntityEntity.fromJson(data as Map<String, dynamic>);
    var verticalList = VerticalEntityEntity.fromJson(baseBean.res as Map<String, dynamic>);
    onSuccess(verticalList.vertical);
  },(error){
    print("【请求失败】${error.toString()}");
    showFailedToast('failed!');
    onError(error);
  });
}

解析Json

使用的是一个JsonToDartBeanAction插件进行解析,只需要通过输入需要解析的Json串,他会自动生成bean类和转换类
以此类为例,我传入的JSON串如下

{
    "msg":"success",
    "res":Object{...},
    "code":0
}

然后他自动生成Bean类以及JSON解析和转换类

@JsonSerializable()
class BaseImageEntityEntity {

	late String msg;
	dynamic res;
	late int code;
  
  BaseImageEntityEntity();

  factory BaseImageEntityEntity.fromJson(Map<String, dynamic> json) => $BaseImageEntityEntityFromJson(json);

  Map<String, dynamic> toJson() => $BaseImageEntityEntityToJson(this);

  @override
  String toString() {
    return jsonEncode(this);
  }
}

这些都是它自动生成的,你每创建Bean类,它就会多一个对应的解析类,然后将添加到convert文件中

图片阅览

UI

Dialog

通过继承Dialog组件实现自定义,通过通过GestureDetector组件为图片添加点击事件,点击区域外部可取消Dialog,使用 Navigator.pop(context);也可以取消当前Dialog

class ImageDialog extends Dialog {
  final String imageUrl;

  const ImageDialog(this.imageUrl, {super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
        width: double.infinity,
        height: double.infinity,
        margin: const EdgeInsets.all(100.0),
        padding: const EdgeInsets.all(50.0),
        decoration: const BoxDecoration(
            color: Color(0x66000000),
            borderRadius: BorderRadius.all(Radius.circular(15.0))),
        child: GestureDetector(
            onTap: () {
              // downloadImage();
              DialogUtil.showDownloadDialog(context, imageUrl);
            },
            child: Image.network(imageUrl,
                errorBuilder: (context, error, stackTrace) {
              return const CircularProgressIndicator();
            }, loadingBuilder: (context, child, progress) {
              if (progress == null) return child;
              return Container(
                alignment: Alignment.center,
                child: CircularProgressIndicator(
                  value: progress.expectedTotalBytes != null
                      ? progress.cumulativeBytesLoaded /
                          progress.expectedTotalBytes!
                      : null,
                ),
              );
            })));
  }
}

下载

下载Dialog与上述无异,此处滤过

UI

调用浏览器进行下载

此处下载功能通过调用原生htmla标签进行下载,但是需要引入一个库,前者是使用html的库,后者是使用http的库

universal_html: ^1.2.1
http: ^0.13.1

引入依赖

import 'package:universal_html/html.dart' as html;
import 'package:http/http.dart' as http;

首先访问图片URL,然后将其进行编码,最后使用html.AnchorElement创建html标签,然后以当前时间为下载完成图片的名字

 void downloadImage() async {
    try {
      final http.Response response = await http.get(Uri.parse(imageUrl));
      final data = response.bodyBytes;
      final base64data = base64Encode(data);
      final a = html.AnchorElement(href: 'data:image/jpeg;base64,$base64data');
      String imageName = 'download_in_${DateTime.now().toString()}.png';
      a.download = imageName;
      a.click();
      a.remove();
    } catch (e) {
      print(e.toString());
    }
  }

Git

Git链接

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/29374.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Spring之IOC 为什么能解耦

1.1 什么是IOC &#xff08;1&#xff09;控制反转&#xff0c;把对象的创建和对象之间的调用过程&#xff0c;都交给Spring进行管理 &#xff08;2&#xff09;使用IOC目的&#xff1a;为了耦合性降低 1.2 IOC的底层原理 &#xff08;1&#xff09;使用的技术&#xff1a;…

完美解决-RuntimeError: CUDA error: device-side assert triggered

网上的解决方案意思是对的&#xff0c;但并没有给出相应的实际解决方法&#xff1a; 问题描述&#xff1a; 当使用ImageFolder方式构建数据集的时候&#xff1a; train_data torchvision.datasets.ImageFolder(train_path, transformtrain_transform)train_loader DataLoad…

学习Git看这一篇就够了

文章目录Git简单介绍官方网址Git是什么版本控制系统的演化Git安装 - Windows版需要熟悉的几个Linux命令Git命令行状态对应目录位置Git命令1. git init2. git status3. git add4. git commit5. git config6. git reset7. git diff练习 - 创建学生管理系统练习提交代码练习修改代…

传感模块:MATEKSYS Optical Flow LIDAR 3901-L0X

传感模块&#xff1a;MATEKSYS Optical Flow & LIDAR 3901-L0X1. 模块介绍2. 规格参数3. 使用方法Step1: 接线方式Step2: 安装方式Step3: 使用范围4. 存在问题4.1 MATEKSYS 3901-L0X 输出协议格式&#xff1f;4.1.1 支持光流计协议(iNav-CXOF)4.1.2 支持光流计激光测距协议…

混合SDN中的安全性问题研究

混合SDN中的安全性问题研究混合SDN中的安全性问题研究1.学习目标2.学习内容3.目前存在的问题4.解决办法1.关于欺骗ARP的讨论2.DDoS攻击探讨5.解决方案现有文献的解决方案6.目前面临的挑战申明&#xff1a; 未经许可&#xff0c;禁止以任何形式转载&#xff0c;若要引用&#xf…

美食杰项目(一)登录注册页

目录前言&#xff1a;具体效果&#xff1a;代码思路相应的组件&#xff1a;具体代码&#xff1a;all页面的具体代码&#xff1a;login页面具体代码&#xff1a;**登录和注册的基本功能都一样所以没有注释**enroll页面的具体代码&#xff1a;路由相关代码&#xff1a;相关引入&a…

Swagger2依赖的版本问题导致其配置文件一直报错的终极解决方案

Swagger2依赖的版本问题 在项目中使用的报错的版本 springboot2.2.1.RELEASE swagger2.9.2导致在写swagger的配置类时&#xff0c;一直引入不了依赖 导入正确的依赖 <!--swagger--> <dependency><groupId>io.springfox</groupId><artifactId>sp…

JIRA on K8s helm部署实战

JIRA on K8s helm部署实战jira on k8s实战waht&#xff1f;架构![在这里插入图片描述](https://img-blog.csdnimg.cn/7b007d9bfb4648c7b1ab816105f51701.png)如何选择chart官方的chartmox 的chart【1】mox chart 安装脚本【2】生产环境的yamljira 的sharedHome 和localHome 的区…

spring源码 - @Condition原理及运用

1.在源码中&#xff0c;在生成beanfinition中有有如一段代码 以下代码逻辑中执行this.conditionEvaluator.shouldSkip返回true直接跳出beandefinition生成逻辑 private <T> void doRegisterBean(Class<T> beanClass, Nullable String name,Nullable Class<? …

实验数据处理

来源 加热冷却温度实验&#xff0c;相同实验参数可能有一次或多次重复实验&#xff0c;一次实验中也可能有多次。如何分别每一次周期&#xff0c;并把每个周期的数据都分析出来&#xff0c;成为一个问题。 解决思想 想根据冷却后的平台划分不同周期&#xff0c;但是由于冷却…

web前端期末大作业【仿12306铁路官网首页】学生网页设计作业源码

&#x1f389;精彩专栏推荐 &#x1f4ad;文末获取联系 ✍️ 作者简介: 一个热爱把逻辑思维转变为代码的技术博主 &#x1f482; 作者主页: 【主页——&#x1f680;获取更多优质源码】 &#x1f393; web前端期末大作业&#xff1a; 【&#x1f4da;毕设项目精品实战案例 (10…

分享5款同类软件中的翘楚,属于是WIN10必备良品

今天要给大家推荐的是5款软件&#xff0c;每个都是同类软件中的个中翘楚,请大家给我高调地使用起来,不用替我藏着掖着。 1.PPT插件——OneKeyTools OK插件是一款免费的PPT插件&#xff0c;让你的PPT制作有无限可能&#xff01;它的功能&#xff0c;太多了&#xff0c;比如图片…

Apache HTTPD 换行解析漏洞(CVE-2017-15715)

漏洞环境 Vulhub 影响版本 Apache 2.4.0~2.4.29 漏洞简介 Apache HTTPD 是一款 HTTP 服务器&#xff0c;其 2.4.0~2.4.29 版本中存在一个解析漏洞&#xff0c;在解析 PHP 时&#xff0c;1.php\x0A 将被按照 PHP 后缀进行解析。 解析漏洞是指服务器应用程序会把某些人为构造…

QT开发实例之常用控件(上)

目录QT控件使用范例设置窗口属性字体形状窗体QPushButton 按钮QLabelQLineEdit 单行文本QComboBox 下拉列表框QFontComboBox 字体下拉列表框QSpinBox 控件QTimeEdit 时间控件QDateEdit 日期控件QScrollBar 滑动条控件QRadioButton 单选按钮QCheckBox 复选框QT控件使用范例 设置…

[附源码]计算机毕业设计JAVA卡牌交易网站

[附源码]计算机毕业设计JAVA卡牌交易网站 项目运行 环境配置&#xff1a; Jdk1.8 Tomcat7.0 Mysql HBuilderX&#xff08;Webstorm也行&#xff09; Eclispe&#xff08;IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持&#xff09;。 项目技术&#xff1a; SSM mybatis M…

华为认证HCIP的持证人数

华为认证hcip多少人通过了考试&#xff1f; hcip通过的人数比hcie多很多&#xff1b; 华为官方并没有披露hcip通过的相关数据。 唯一可以借鉴的数据&#xff1a;截止到2020年HCIE的持证人数大约在15000左右。 有多少人过了hcip其实并不是一件非常重要的事&#xff0c;重要的…

[附源码]java毕业设计演唱会售票系统

项目运行 环境配置&#xff1a; Jdk1.8 Tomcat7.0 Mysql HBuilderX&#xff08;Webstorm也行&#xff09; Eclispe&#xff08;IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持&#xff09;。 项目技术&#xff1a; SSM mybatis Maven Vue 等等组成&#xff0c;B/S模式 M…

Jan Ozer:高清直播互动场景下的硬编码如何选型?

前言 高清直播逐渐普及&#xff0c;硬编码也成为大势所趋。在 RTE 2022 大会上&#xff0c;来自 NETINT 的 Jan Ozer 通过一系列的对比测试结果&#xff0c;详细分享了如何为高清直播互动场景进行硬编码的技术选型。 本文内容基于演讲内容进行整理&#xff0c;为方便阅读略有删…

膜拜,华为18级工程师用349页构建高可用Linux服务器,其实并不难

前言 本文是华为高级工程师从Linux服务器性能调优与高可用集群构建、MySQL性能调优与高可用架构设计、自动化运维与Linux系统安全等多角度讲解构建高可用Linux服务器的方法和技巧。 希望大家能通过本文掌握 Linux 的精髓&#xff0c;轻松而愉快地工作&#xff0c;从而提高自己…

SwiftUI 后台刷新多个 Section 导致 global index in collection view 与实际不匹配问题的解决

问题现象 在复杂布局的 SwiftUI 视图中,用段(Section)来搭配组合其它容器视图(Form 或 List)无疑极具默契性。不过,在多个 Section 对应的数据被后台多线程修改时,往往会发生难以定位的错误,甚至导致 App 直接崩溃: 如上图所示,我们试图在后台线程更新多个 Section …