一步一步学习使用LiveBindings(14)TListView进阶使用(2),打造天气预报程序

在《一步一步学习使用LiveBindings(12)》课中,非常详细的介绍了如何在设计时手工的编辑DynamicAppearance类型的项,大大方便了构建自定义的列表项。但是很多情况下,仍然要面对编程创建列表项的情形,特别是当要实现自定义的列表项时,将不得不面对编程创建列表项的挑战。

注意:更想的自定义列表项的的方法是为 TListView 组件编写自定义样式;将组件放入一个包中,安装到 IDE 中,然后从对象检查器窗口使用它。这样就可以在多个项目中重复的使用。

这一课将主要介绍如下内容:

  • 使用TRestClient从远端服务器获取服务。
  • 解析JSON,根据JOSN的内容,创建TListViewItem。
  • 根据JSON的内容,生成不同的列表项呈现。

这一课将是创建自定义列表项的基础,掌握了这一课的内容,基本对TListView,也就没有大的问题了。

1. 关于天气预报网站OpenWeatherMap:

OpenWeatherMap 是一个提供全球天气数据的知名服务平台,主要面向开发者、企业和个人用户。以下是它的核心特点:

  1. 实时与预报天气数据
    提供实时天气、分钟级降水预报、短期(5天)及长期(16天)预报,甚至历史天气数据。
    覆盖全球任意坐标(包括偏远地区),数据来源自气象站、卫星和雷达。
  2. 多样化的数据接口
    通过 REST API 和 地图图层API 提供数据,支持JSON/XML格式。
    免费层有限制(如60次/分钟调用),付费层可获取更高精度(如小时级降水)和更多功能。
  3. 丰富的天气指标
    温度、湿度、风速、气压、降水、紫外线指数等。
    特殊数据如空气质量(PM2.5)、花粉浓度、自然灾害警报等需订阅高级服务。
  4. 应用场景广泛
    开发者常用其API集成到移动应用、网站或IoT设备(如智能家居)。
    企业用户可能用于物流、农业或旅游行业的天气分析。
  5. 数据可视化工具
    提供交互式地图,可叠加云层、降水等实时天气图层。
  6. 费用与权限
    免费方案 适合小规模测试(如1,000次/天API调用)。
    商业用途需选择付费计划(如企业级数据或高频率访问)。
  7. 数据覆盖与更新
    覆盖40多万个城市,部分数据更新频率可达每分钟。
    注意事项
    免费数据可能不如专业气象机构精确,付费服务(如History API或Agro API)更适合高要求场景。
    使用前需注册获取API Key,并遵守其条款(如注明数据来源)。
    如果需要替代方案,可考虑 WeatherAPI、AccuWeather 或 ClimaCell(现为Tomorrow.io)。具体选择取决于精度需求和预算。

在该网站上查询北京的天气如下:

网站列出了8天的天气,本课的小程序的效果如下:

本课示例代码来自《Delphi Cookbook》

你需要在该网站注册一个账号,然后申请一个免费的API Key,不过使用《Delphi Cookbook》作者的api key一样可以使用,但是这个示例调用的是旧版本的API,新版本的API已经进化到3.0,可以使用本课学到的知识打造一个真正有价值的世界天气预报应用程序。

2. 构建多设备应用程序。

1. 单击主菜单中的 File > New > Multi-Device Application - Delphi > Header/Footer ,创建一个新的多设备应用程序。

建议立即单击工具栏上的Save All按钮,将单元文件保存为MainFormU.pas,将项目保存为WeatherForecastEx.dproj。

项目结构应该像这样:

2.1 设置用户界面

用户界面大概按如下的步骤:

  1. Header/Footer会自动添加Header和Footer两个TToolBar,在Header上面添加了一个名为HeaderLabel的TLabel控件,指定其Text属性为"天气预报小程序"。

  2. 在Header上添加一个TAniIndictor控件,设置其Align属性为Right,这个控件将显示一个动态的指示器,提供用户运行中的反馈。

  3. 在Header下面添加一个Align为Top的TPanel控件,在上面放2个TEdit,分别命名为EditCity和EditCountry,在最右侧放一个TButton控件,指定name属性为btnGetForecasts,将EditCity的Align设置为Client,EditCountry和btnGetForecasts的Align指定为Right。

  4. 在Footer上放一个TLable控件,指定其name属性为lblInfo,用来显示一些系统消息。

  5. 接下来放一个Align为Client的TListView控件,指定其ItemAppearance.ItemAppearance属性为Custom,将用来编程控制Item。

  6. 最后,手一个TRestClient和TRestRequest控件到主窗体,指定TRestRequest.Client属性为TRestClient。

现在用户界面相关的控年已经准备好了。

2.2 测试天气预报服务

接下来,打开Delphi主菜单的 Tools >REST Debugger,d在弹出的窗口的第一页,Method指定为Get,在URL文本框上指定URL为:
http://api.openweathermap.org/data/2.5/forecast

Content-Type使用默认的Application/json

接下来切换到Parameterss页,添加如下的键值对:

单击右上角的"Send Request"按钮,如果URL构建成功,则Response会返回200-OK状态,Response的Header区可以看到请求的头信息,Body区则可以看到具体的JSON数据。

通过分析JSON,可以得知应用程序将要提供哪些数据给用户,从而构建真正的用户界面。

整个JSON包含了一个list数组,每个数组里面的元素内容如下:

  • dt:Unix时间戳值。
  • main.temp_min:指定时间戳范围内的最低温度。
  • main.temp_max: 指定时间戳范围内的最高温度。
  • weather.main: 天气情况的英语表达。
  • weather.description:天气情况的本地语言表达。

3. 获取天气JSON数据,并进行JSON解析,生成用户界面

1. 在主窗体加载时,将会初始化RestClient和RESTRequest两个控件,FormCreate事件如下:

Pascal 复制代码
// 主窗体创建时触发的事件处理过程
procedure TMainForm.FormCreate(Sender: TObject);
var
  LocaleService: IFMXLocaleService;  // 声明一个FMX本地化服务接口变量
begin
  // 检查当前平台是否支持IFMXLocaleService接口
  if TPlatformServices.Current.SupportsPlatformService(IFMXLocaleService) then
  begin
    // 获取FMX本地化服务接口实例
    LocaleService := TPlatformServices.Current.GetPlatformService
      (IFMXLocaleService) as IFMXLocaleService;
      
    // 获取当前系统的语言ID(例如:"en-US"、"zh-CN"等)
    Lang := LocaleService.GetCurrentLangID;
  end
  else
    // 如果不支持本地化服务,则默认使用美国英语('US')
    Lang := 'US';

  // 设置国家/地区文本框默认值为中国('CN')
  EditCountry.Text := 'CN';
  
  // 设置REST客户端的基础URL为OpenWeatherMap API地址
  RESTClient1.BaseURL := 'http://api.openweathermap.org/data/2.5';
  
  // 设置REST请求的资源路径,包含参数占位符:
  // {country} - 国家/地区代码
  // {lang} - 语言代码
  // {APPID} - API密钥
  RESTRequest1.Resource :=
    'forecast?q={country}&mode=json&lang={lang}&units=metric&APPID={APPID}';
    
  // 设置REST请求中的APPID参数值
  RESTRequest1.Params.ParameterByName('APPID').Value := APPID;
  
  // 初始化时将活动指示器设置为不可见(不显示加载动画)
  AniIndicator1.Visible := False;
end;

当访问一个RESTful服务器时,一般在TRESTClient控件中指定基本的URL,而查询字符串参数则在TRESTReuqest控件中指定。花括号内是查询字符串参数的占位和会,可以通过RESTRequest1.Params.ParameterByName这样的语法来进行设置。

2.为天气预报的刷新按钮添加事件处理代码,这里将使用标准的RESTRequest1.ExecuteAsync异步获取服务器端数据,获取完成后会执行一个匿名方法。

在这个事件处理代码中,调用了3个自定义的过程:

Pascal 复制代码
// 在列表视图中添加日期分组标题项
// 参数:
//   AItems: 列表视图的项集合
//   ADateStr: 要显示的日期字符串(格式为yyyy/mm/dd)
procedure TMainForm.AddHeader(AItems: TListViewItems; const ADateStr: string);
Pascal 复制代码
// 在列表视图中添加单个天气预报项
// 参数:
//   AItems: 列表视图的项集合
//   ADateTime: 预报日期时间
//   ADescription: 天气描述(如"晴"、"多云"等)
//   ATempMin: 最低温度
//   ATempMax: 最高温度
procedure TMainForm.AddForecastItem(AItems: TListViewItems; 
  ADateTime: TDateTime; const ADescription: string; 
  ATempMin, ATempMax: Double);
Pascal 复制代码
// 在列表视图中添加日期分组的汇总信息(当天温度极值)
// 参数:
//   AItems: 列表视图的项集合
//   AMinTemp: 当天最低温度
//   AMaxTemp: 当天最高温度
procedure TMainForm.AddFooter(AItems: TListViewItems; 
  AMinTemp, AMaxTemp: Double);

有了这些子程序的辅助,事件处理的代码就会相对简洁不少,完整的单击事件处理代码如下所示:

Pascal 复制代码
// 获取天气预报按钮点击事件处理过程
procedure TMainForm.btnGetForecastsClick(Sender: TObject);
begin
  // 清空列表视图中的所有项
  ListView1.Items.Clear;
  
  // 设置REST请求中的country参数值,将城市和国家用逗号连接
  RESTRequest1.Params.ParameterByName('country').Value :=
    String.Join(',', [EditCity.Text, EditCountry.Text]);
    
  // 设置REST请求中的lang参数值,使用之前获取的语言ID
  RESTRequest1.Params.ParameterByName('lang').Value := Lang;
  
  // 显示并启用活动指示器(加载动画)
  AniIndicator1.Visible := True;
  AniIndicator1.Enabled := True;
  
  // 禁用按钮防止重复点击
  btnGetForecasts.Enabled := False;
  
  // 异步执行REST请求
  RESTRequest1.ExecuteAsync(
    procedure
    var
      LForecastDateTime: TDateTime;      // 预报日期时间
      LJValue: TJSONValue;               // JSON值对象
      LJObj, LMainForecast,             // JSON对象
      LForecastItem, LJObjCity: TJSONObject;
      LJArrWeather, LJArrForecasts: TJSONArray;  // JSON数组
      LTempMin, LTempMax: Double;       // 最低和最高温度
      LDay, LLastDay: string;           // 当前日期和上一个日期
      LWeatherDescription: string;      // 天气描述
      LAppRespCode: string;             // 应用响应代码
      LMinInTheDay, LMaxInTheDay: Double; // 当天最低和最高温度

    begin
      try
        // 将响应内容转换为JSON对象
        LJObj := RESTRequest1.Response.JSONValue as TJSONObject;

        // 检查错误响应
        LAppRespCode := LJObj.GetValue('cod').Value;
        if LAppRespCode.Equals('404') then
        begin
          // 城市未找到的错误处理
          lblInfo.Text := '没有找到指定城市';
          Exit;
        end;
        if not LAppRespCode.Equals('200') then
        begin
          // 其他错误处理
          lblInfo.Text := '错误 ' + LAppRespCode;
          Exit;
        end;

        // 解析天气预报数据
        LMinInTheDay := 1000;    // 初始化当天最低温度为极大值
        LMaxInTheDay := -LMinInTheDay; // 初始化当天最高温度为极小值
        
        // 获取预报列表数组
        LJArrForecasts := LJObj.GetValue('list') as TJSONArray;
        
        // 遍历所有预报项
        for LJValue in LJArrForecasts do
        begin
          // 获取单个预报项对象
          LForecastItem := LJValue as TJSONObject;
          
          // 将Unix时间戳转换为Delphi日期时间
          LForecastDateTime := UnixToDateTime((LForecastItem.GetValue('dt')
            as TJSONNumber).AsInt64);
            
          // 获取主预报信息对象
          LMainForecast := LForecastItem.GetValue('main') as TJSONObject;
          
          // 获取最低和最高温度
          LTempMin := (LMainForecast.GetValue('temp_min')
            as TJSONNumber).AsDouble;
          LTempMax := (LMainForecast.GetValue('temp_max')
            as TJSONNumber).AsDouble;
            
          // 获取天气描述数组
          LJArrWeather := LForecastItem.GetValue('weather') as TJSONArray;
          
          // 获取第一个天气描述
          LWeatherDescription := TJSONObject(LJArrWeather.Items[0])
            .GetValue('description').Value;
            
          // 格式化日期为yyyy/mm/dd
          LDay := FormatDateTime('yyyy/mm/dd', DateOf(LForecastDateTime));
          
          // 检查是否是新的日期
          if LDay <> LLastDay then
          begin
            // 如果不是第一个日期,添加前一天的最高和最低温度的统计信息
            if not LLastDay.IsEmpty then
            begin
              AddFooter(ListView1.Items, LMinInTheDay, LMaxInTheDay);
            end;
            
            // 添加新日期的标题
            AddHeader(ListView1.Items, LDay);
            
            // 重置当天温度极值
            LMinInTheDay := 1000;
            LMaxInTheDay := -LMinInTheDay;
          end;
          
          // 保存当前日期供下次比较
          LLastDay := LDay;
          
          // 更新当天最低和最高温度
          LMinInTheDay := Min(LMinInTheDay, LTempMin);
          LMaxInTheDay := Max(LMaxInTheDay, LTempMax);

          // 添加预报项到列表视图
          AddForecastItem(ListView1.Items, LForecastDateTime,
            LWeatherDescription, LTempMin, LTempMax);
        end; // 结束预报项遍历

        // 添加最后一天的汇总信息
        if not LLastDay.IsEmpty then
          AddFooter(ListView1.Items, LMinInTheDay, LMaxInTheDay);

        // 获取并显示城市和国家信息
        LJObjCity := LJObj.GetValue('city') as TJSONObject;
        lblInfo.Text := LJObjCity.GetValue('name').Value + ', ' +
          LJObjCity.GetValue('country').Value;

      finally
        // 无论成功或失败,都执行以下清理操作
        AniIndicator1.Visible := False;  // 隐藏活动指示器
        AniIndicator1.Enabled := False;  // 禁用活动指示器
        btnGetForecasts.Enabled := True; // 重新启用按钮
      end;
    end);
end;

代码的详细解说如下:

  1. RESTRequest1.Params 是 TRESTRequest 组件中用于管理所有请求参数的集合属性,它允许你在发送 HTTP 请求前配置各种类型的参数,参数country配置为"城市,国家",这是API的定义需求,直接写城市有时也能找到。

  2. RESTRequest1.Response包含很多属性用来获取来自服务器的响应,如果服务器端返回JOSN数据,则使用JSONValue属性可以获取到JSON实例。

  3. 服务器返回的JSON中包含了状态码,可以根据状态码判断查询成功还是失败。

  4. 接下来对JSON中的list数组进行了解析,LMinInTheDay和LMinInTheDay 变量是统计1天中最高与最低温度的变量,在每天的日期发生变化后清零,在每天会进行Min与Max的统计运算。

  5. 在每天的日期发生变化后,会调用AddHeader添加页眉,在添加页眉之前,总是会先对上一个日期结束位置调用AddFooter添加页脚。

  6. 在最后一天也会添加一个页脚,避免遗漏。

  7. 在每一天的日期记录中,调用AddForecastItem添加项。

  8. 在代码最后为主窗体的footer区的TLabel控件也设置了显示文本。

接下来看看3个过程的具体实现,它们并未涉及到具体的显示位置等逻辑,这一切要在TListView的OnUpdateObject事件中完成。

Pascal 复制代码
// 添加日期分组标题项到列表视图
procedure TMainForm.AddHeader(AItems: TAppearanceListViewItems;
  const ADay: String);
var
  LItem: TListViewItem;  // 声明列表项变量
begin
  // 在列表项集合中添加新项
  LItem := AItems.Add;

  // 设置该项为分组标题类型
  LItem.Purpose := TListItemPurpose.Header;

  // 在名为'HeaderLabel'的绘制对象中设置日期文本
  LItem.Objects.FindDrawable('HeaderLabel').Data := ADay;
end;

// 添加单个天气预报项到列表视图
procedure TMainForm.AddForecastItem(AItems: TAppearanceListViewItems;
  const AForecastDateTime: TDateTime;  // 预报日期时间
  const AWeatherDescription: String;   // 天气描述文本
  const ATempMin, ATempMax: Double);   // 最低/最高温度
var
  LItem: TListViewItem;  // 声明列表项变量
begin
  // 在列表项集合中添加新项
  LItem := AItems.Add;

  // 设置'WeatherDescription'绘制对象的数据:
  // 格式为"小时时+天气描述"(如"14时 多云")
  LItem.Objects.FindDrawable('WeatherDescription').Data :=
    FormatDateTime('HH', AForecastDateTime) + '时  ' + AWeatherDescription;

  // 设置'MinTemp'绘制对象的数据:
  // 格式为"最低温度°"(如"12.50°")
  LItem.Objects.FindDrawable('MinTemp').Data :=
    FormatFloat('#0.00', ATempMin) + '°';

  // 设置'MaxTemp'绘制对象的数据:
  // 格式为"最高温度°"(如"24.80°")
  LItem.Objects.FindDrawable('MaxTemp').Data :=
    FormatFloat('#0.00', ATempMax) + '°';
end;

// 添加日期分组汇总信息到列表视图
procedure TMainForm.AddFooter(AItems: TAppearanceListViewItems;
  const LMinInTheDay, LMaxInTheDay: Double);  // 当日最低/最高温度
var
  LItem: TListViewItem;  // 声明列表项变量
begin
  // 在列表项集合中添加新项
  LItem := AItems.Add;

  // 设置该项为分组脚注类型
  LItem.Purpose := TListItemPurpose.Footer;

  // 设置脚注文本:
  // 格式为"最低 XX.XX° 最高 XX.XX°"(如"最低 12.50° 最高 24.80°")
  LItem.Text := Format('最低 %2.2f° 最高 %2.2f°', [LMinInTheDay, LMaxInTheDay]);
end;

这里的过程仅负则添加和设置文本或Data数据,每一次AItems.Add过程调用之后,需要指定Purpose为具体的项类型,以便于OnUpdateObjects进行处理。

每次在Item.Txt或者是FindDrawable语句触发时,会自动调用OnUpdateObject来实现显示对象的真正呈现工作,事件处理代码如下:

Pascal 复制代码
// 列表视图项更新事件处理过程
procedure TMainForm.ListView1UpdateObjects(const Sender: TObject;
  const AItem: TListViewItem);
var
  AQuarter: Double;     // 用于存储四分之一宽度的变量
  lb: TListItemText;    // 列表项文本对象引用
  lListView: TListView; // 列表视图引用
begin
  // 将Sender转换为TListView类型
  lListView := Sender as TListView;
  
  // 根据列表项的目的类型进行不同处理
  case AItem.Purpose of
    // 处理普通列表项
    TListItemPurpose.None:
      begin
        // 计算每列宽度(总宽度减去左右边距后分成4份)
        AQuarter := (lListView.Width - lListView.ItemSpaces.Left -
          lListView.ItemSpaces.Right) / 4;
        
        // 设置普通项的高度为24像素
        AItem.Height := 24;

        // 处理天气描述文本控件
        lb := TListItemText(AItem.Objects.FindDrawable('WeatherDescription'));
        if not Assigned(lb) then  // 如果对象不存在则创建
        begin
          lb := TListItemText.Create(AItem);    // 创建新文本对象
          lb.PlaceOffset.X := 0;                // 设置X偏移为0
          lb.TextAlign := TTextAlign.Leading;  // 文本左对齐
          lb.Name := 'WeatherDescription';     // 设置对象名称
        end;
        lb.PlaceOffset.X := 0;  // 确保X偏移为0

        // 处理最低温度文本控件
        lb := TListItemText(AItem.Objects.FindDrawable('MinTemp'));
        if not Assigned(lb) then  // 如果对象不存在则创建
        begin
          lb := TListItemText.Create(AItem);  // 创建新文本对象
          lb.TextAlign := TTextAlign.Trailing;  // 文本右对齐
          lb.TextColor := TAlphaColorRec.Blue;  // 设置文本颜色为蓝色
          lb.Name := 'MinTemp';  // 设置对象名称
        end;
        lb.PlaceOffset.X := AQuarter * 2;  // 设置X位置为第二列
        lb.Width := AQuarter;  // 设置宽度为四分之一宽度

        // 处理最高温度文本控件
        lb := TListItemText(AItem.Objects.FindDrawable('MaxTemp'));
        if not Assigned(lb) then  // 如果对象不存在则创建
        begin
          lb := TListItemText.Create(AItem);  // 创建新文本对象
          lb.TextAlign := TTextAlign.Trailing;  // 文本右对齐
          lb.TextColor := TAlphaColorRec.Red;  // 设置文本颜色为红色
          lb.Name := 'MaxTemp';  // 设置对象名称
        end;
        lb.PlaceOffset.X := AQuarter * 3;  // 设置X位置为第三列
        lb.Width := AQuarter;  // 设置宽度为四分之一宽度
      end;
      
    // 处理列表头项
    TListItemPurpose.Header:
      begin
        // 设置头部高度为48像素
        AItem.Height := 48;
        
        // 处理头部标签文本控件
        lb := TListItemText(AItem.Objects.FindDrawable('HeaderLabel'));
        if not Assigned(lb) then  // 如果对象不存在则创建
        begin
          lb := TListItemText.Create(AItem);  // 创建新文本对象
          lb.TextAlign := TTextAlign.Center;  // 文本居中对齐
          lb.Align := TListItemAlign.Center;  // 控件居中对齐
          lb.TextColor := TAlphaColorRec.Red;  // 设置文本颜色为红色
          lb.Name := 'HeaderLabel';  // 设置对象名称
        end;
        lb.PlaceOffset.Y := AItem.Height / 4;  // 设置Y偏移为高度的四分之一
      end;
      
    // 处理列表脚注项
    TListItemPurpose.Footer:
      begin
        // 设置脚注文本右对齐
        AItem.Objects.TextObject.TextAlign := TTextAlign.Trailing;
      end;
  end;
end;

可以看到,在OnUpdateObject事件中,动态的创建了可绘制项元素,动态计算并设置其宽度和高度,所有与呈现相关的工作都在这个事件中得以完成。

总结

这篇文章主要分享了如下几个知识点:

  1. 使用TRESTClient和TRESTResponse访问远程服务器,获取JSON数据源。
  2. 使用System.JSON命名空间中的类解析JSON。
  3. 根据JSON数据源动态创建列表项。
  4. 处理OnUpdateObject事件创建列表项的呈现对象。

相信通过对这篇文章的学习,可以对TListView有较为深入的理解。

相关推荐
lincats7 小时前
一步一步学习使用LiveBindings(15)TListView进阶使用(3),创建自定义的列表项打造天气预报程序
delphi 12.3·firedac·firemonkey·tlistview
lincats2 天前
一步一步学习使用LiveBindings(13) TListView的进阶使用(1)
delphi·livebindings·delphi 12.3·firemonkey·tlistview
lincats3 天前
一步一步学习使用LiveBindings(12) LiveBindings与具有动态呈现的TListView
delphi·livebindings·delphi 12.3·firemonkey
chilavert3184 天前
技术演进中的开发沉思-62 DELPHI VCL系列:VCL下的设计模式
开发语言·delphi
lincats5 天前
一步一步学习使用LiveBindings(11) 绑定到自定义外观的ListBox
list·delphi·delphi 12.3·firedac·firemonkey·tlistview
lincats6 天前
# 一步一步学习使用LiveBindings(10) LiveBindings绑定到漂亮的TCombobox
ide·delphi·livebindings·delphi 12.3
lincats7 天前
一步一步学习使用LiveBindings(9) LiveBindings图像绑定与自定义绑定方法(2)
delphi·livebindings·delphi 12.3·firedac·firemonkey
lincats9 天前
一步一步学习使用LiveBindings(8) 使用向导创建用户界面,绑定格式化入门
delphi·livebindings·delphi 12.3·firedac·firemonkey
lincats13 天前
一步一步学习使用LiveBindings(7) 实现对JSON数据的绑定
android·delphi·livebindings·delphi 12.3·firedac