鸿蒙开发之热榜APP

3,120 阅读3分钟

介绍

安卓和 IOS 应用市场中热榜 APP 层出不穷,但鸿蒙市场乏善可陈,因此我开发了一个鸿蒙版热榜 APP 来填补市场的空白,同时也提升了鸿蒙开发技能树的熟练度和深度。

主界面的截图如下。

Screenshot 2024-04-29 083053.png

Screenshot 2024-05-11 140327.png

Screenshot 2024-05-11 141146.png

功能

  1. Tab 栏选择不同媒体(掘金热榜、知乎热榜、36kr等),加载热榜,支持下拉刷新。
  2. 点击新闻卡片跳转至 Webview 页面
  3. Webview 页面加载具体移动端新闻页面

代码讲解

1.入口页

首先来看我们的入口页,我把组件全部分装在TabBar子组件中,因此首页全部包裹在一个Column根组件中,非常的直观易懂。

import TabBar from '../view/TabBar';
import { CommonConstant as Const } from '../common/constant/CommonConstant';

/**
 * The Index is the entry point of the application.
 */
@Entry
@Component
struct Index {

  build() {
    Column() {
      TabBar()
    }
    .width(Const.FULL_WIDTH)
    .backgroundColor($r('app.color.listColor'))
    .justifyContent(FlexAlign.Center)
  }
}

2.TabBar组件

@Component
export default struct TabBar {
  @State tabBarArray: NewsTypeBean[] = [{name:'juejin',id:0},{name:'thepaper',id:1},{name:'zhihu',id:2}];
  @State currentIndex: number = 0;
  @State currentPage: number = 1;

  @Builder TabBuilder(index: number) {
    Column() {
      Text(this.tabBarArray[index].name)
        .height(Const.FULL_HEIGHT)
        .padding({ left: Const.TabBars_HORIZONTAL_PADDING, right: Const.TabBars_HORIZONTAL_PADDING })
        .fontSize(this.currentIndex === index ? Const.TabBars_SELECT_TEXT_FONT_SIZE : Const.TabBars_UN_SELECT_TEXT_FONT_SIZE)
        .fontWeight(this.currentIndex === index ? Const.TabBars_SELECT_TEXT_FONT_WEIGHT : Const.TabBars_UN_SELECT_TEXT_FONT_WEIGHT)
        .fontColor($r('app.color.fontColor_text3'))
    }
  }

  build() {
    Tabs() {
      ForEach(this.tabBarArray, (tabsItem: NewsTypeBean) => {
        TabContent() {
          Column() {
            NewsList({ currentIndex: $currentIndex })
          }
        }
        .tabBar(this.TabBuilder(tabsItem.id))
      }, (item: NewsTypeBean) => JSON.stringify(item));
    }
    .barHeight(Const.TabBars_BAR_HEIGHT)
    .barMode(BarMode.Scrollable)
    .barWidth(Const.TabBars_BAR_WIDTH)
    .onChange((index: number) => {
      this.currentIndex = index;
      this.currentPage = 1;
    })
    .vertical(false)
  }
}

我们来仔细看下TabBar组件,我们的Tab栏都可以在 tabBarArray 中进行配置,当然这里一般是从后端请求过来的,但我对接的后端没有这个API,因此直接在代码里配置了。currentIndex:表示当前选中的标签索引,默认为0(即第一项,掘金)。 currentPage:表示当前的页码,初始值为1。

TabBuilder用到了 Builder 装饰器,可以批量生成 Tab 子组件,用于构建每个标签的样式和内容。它接收一个index(索引)参数,然后使用这个索引来访问tabBarArray数组中的元素,显示对应的新闻类型名称。此外,根据是否是当前选中的标签,设置不同的字体大小和字体粗细。最后,设置一些样式,如高度、内边距、字体大小、字体粗细和颜色。

ForEach循环遍历tabBarArray数组,为每种新闻类型创建一个TabContent,这里面包含一个Column,用来显示新闻列表NewsList组件。每个TabContent使用TabBuilder方法设置其对应的标签。设置标签栏的高度、模式、宽度,并定义当标签改变时的行为,即更新currentIndex和重置currentPage

下面我们深入探究下 NewsList 组件。

3.NewsList 组件

这个组件代码量很大,实际上很简单,它定义了一个用于展示新闻列表的组件,主要功能包括:

  1. 加载后端数据:通过changeCategory方法,当用户选择不同的新闻类别时,它会重新获取对应类别的新闻数据。这涉及到网络请求(模拟为NewsViewModel.getNewsList),成功时更新模型中的新闻数据和状态,失败时显示错误信息。

  2. 动态布局:根据newsModel.pageState的值(成功、加载中、失败)构建不同的UI布局。成功时显示新闻列表,加载中时显示加载动画,加载失败时显示错误提示。

  3. 定制化的刷新和加载布局:通过自定义的CustomRefreshLoadLayoutClass来实现下拉刷新和上拉加载更多的效果,增强了用户体验。

 @State newsModel: NewsModel = new NewsModel();
  @Watch('changeCategory') @Link currentIndex: number;
  changeCategory() {
    this.newsModel.currentPage = 1;
    NewsViewModel.getNewsList(Const.NEWS_TYPE_Array[this.currentIndex])
      .then((data: NewsData[]) => {
        console.log(JSON.stringify(data))
        this.newsModel.pageState = PageState.Success;
        if (data) {
          // this.newsModel.currentPage++;
          this.newsModel.hasMore = true;
        } else {
          this.newsModel.hasMore = false;
        }
        this.newsModel.newsData = data;
      })
      .catch((err: string | Resource) => {
        promptAction.showToast({
          message: err,
          duration: Const.ANIMATION_DURATION
        });
        this.newsModel.pageState = PageState.Fail;
      });
  }

  aboutToAppear() {
    // Request news data.
    this.changeCategory();
  }

  build() {
    Column() {
      if (this.newsModel.pageState === PageState.Success) {
        this.ListLayout()
      } else if (this.newsModel.pageState === PageState.Loading) {
        this.LoadingLayout()
      } else {
        this.FailLayout()
      }
    }
    .width(Const.FULL_WIDTH)
    .height(Const.FULL_HEIGHT)
    .justifyContent(FlexAlign.Center)
    .onTouch((event: TouchEvent | undefined) => {
      if (event) {
        if (this.newsModel.pageState === PageState.Success) {
          listTouchEvent(this.newsModel, event,this.currentIndex);
        }
      }
    })
  }

  @Builder LoadingLayout() {
    CustomRefreshLoadLayout({ customRefreshLoadClass: new CustomRefreshLoadLayoutClass(true,
      $r('app.media.ic_pull_up_load'), $r('app.string.pull_up_load_text'), this.newsModel.pullDownRefreshHeight) })
  }

  @Builder ListLayout() {
    List() {
      ListItem() {
        RefreshLayout({
          refreshLayoutClass: new CustomRefreshLoadLayoutClass(this.newsModel.isVisiblePullDown, this.newsModel.pullDownRefreshImage,
            this.newsModel.pullDownRefreshText, this.newsModel.pullDownRefreshHeight)
        })
      }

      ForEach(this.newsModel.newsData, (item: NewsData) => {
        ListItem() {
          NewsItem({ newsData: item })
        }
        // .height($r('app.float.news_list_height'))
        .backgroundColor($r('app.color.white'))
        .margin({ top: $r('app.float.news_list_margin_top') })
        .borderRadius(Const.NewsListConstant_ITEM_BORDER_RADIUS)

      }, (item: NewsData, index?: number) => JSON.stringify(item) + index)

      ListItem() {
        if (this.newsModel.hasMore) {
          LoadMoreLayout({
            loadMoreLayoutClass: new CustomRefreshLoadLayoutClass(this.newsModel.isVisiblePullUpLoad, this.newsModel.pullUpLoadImage,
              this.newsModel.pullUpLoadText, this.newsModel.pullUpLoadHeight)
          })
        } else {
          NoMoreLayout()
        }
      }
    }
    .width(Const.NewsListConstant_LIST_WIDTH)
    .height(Const.FULL_HEIGHT)
    .margin({ left: Const.NewsListConstant_LIST_MARGIN_LEFT, right: Const.NewsListConstant_LIST_MARGIN_RIGHT })
    .backgroundColor($r('app.color.listColor'))
    .divider({
      color: $r('app.color.dividerColor'),
      strokeWidth: Const.NewsListConstant_LIST_DIVIDER_STROKE_WIDTH,
      endMargin: Const.NewsListConstant_LIST_MARGIN_RIGHT
    })
    // Remove the rebound effect.
    .edgeEffect(EdgeEffect.None)
    .offset({ x: 0, y: `${this.newsModel.offsetY}${CommonConstant.LIST_OFFSET_UNIT}` })
    .onScrollIndex((start: number, end: number) => {
      // Listen to the first index of the current list.
      this.newsModel.startIndex = start;
      this.newsModel.endIndex = end;
    })
  }

  @Builder FailLayout() {
    Image($r('app.media.none'))
      .height(Const.NewsListConstant_NONE_IMAGE_SIZE)
      .width(Const.NewsListConstant_NONE_IMAGE_SIZE)
    Text($r('app.string.page_none_msg'))
      .opacity(Const.NewsListConstant_NONE_TEXT_opacity)
      .fontSize(Const.NewsListConstant_NONE_TEXT_size)
      .fontColor($r('app.color.fontColor_text3'))
      .margin({ top: Const.NewsListConstant_NONE_TEXT_margin })
  }
}

对接后端的函数如下所示,根据 path ,也就是相应的媒体分类(36kr、zhihu、juejin等)请求热榜数据。

getNewsList( path: string): Promise<NewsData[]> {
  return new Promise(async (resolve: Function, reject: Function) => {
    console.log(path)
    let url = "https://api-hot.efefee.cn/" + path;
    httpRequestGet(url).then((data: ResponseResult) => {
      console.log(data.msg as string)
      if (data.code === 200) {
        console.log(data.msg as string)
        resolve(data.data);
      } else {
        Logger.error('getNewsList failed', JSON.stringify(data));
        reject($r('app.string.page_none_msg'));
      }
    }).catch((err: Error) => {
      Logger.error('getNewsList failed', JSON.stringify(err));
      reject($r('app.string.http_error_message'));
    });
  });
}

api-hot.efefee.cn/juejin 为例,返回的数据格式如下所示:

image.png

我们的返回结果主要有 code、title、data 等属性,实际只用到了 code 和 data,而业务逻辑只用到了 data 属性,因此我们只 resolve 了 data 属性即 resolve(data.data),然后把每个 item 传输给 NewsItem 组件。

我们的业务逻辑是放在 NewsItem 组件中的,显示新闻标题,封面以及对应的 webview 链接。我们来看看 NewsItem 组件是如何实现的。

4. NewsItem 组件

import { CommonConstant, CommonConstant as Const } from '../common/constant/CommonConstant';
import { NewsData, NewsFile } from '../viewmodel/NewsViewModel';
import router from '@ohos.router';

/**
 * The news list item component.
 */
@Component
export default struct NewsItem {
  private newsData: NewsData = new NewsData();

  build() {
    Column() {
      Row() {
        Image($r('app.media.news'))
          .width(Const.NewsTitle_IMAGE_WIDTH)
          .height($r('app.float.news_title_image_height'))
          .objectFit(ImageFit.Fill)
        Text(this.newsData.title)
          .fontSize(Const.NewsTitle_TEXT_FONT_SIZE)
          .fontColor($r('app.color.fontColor_text'))
          .width(Const.NewsTitle_TEXT_WIDTH)
          .maxLines(1)
          .margin({ left: Const.NewsTitle_TEXT_MARGIN_LEFT })
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .fontWeight(Const.NewsTitle_TEXT_FONT_WEIGHT)
      }
      .alignItems(VerticalAlign.Center)
      .height($r('app.float.news_title_row_height'))
      .margin({
        top: $r('app.float.news_title_row_margin_top'),
        left: Const.NewsTitle_IMAGE_MARGIN_LEFT
      })

      Text(this.newsData.title)
        .fontSize(Const.NewsContent_FONT_SIZE)
        .fontColor($r('app.color.fontColor_text'))
        .height(Const.NewsContent_HEIGHT)
        .width(Const.NewsContent_WIDTH)
        .maxLines(Const.NewsContent_MAX_LINES)
        .margin({ left: Const.NewsContent_MARGIN_LEFT, top: Const.NewsContent_MARGIN_TOP })
        .textOverflow({ overflow: TextOverflow.Ellipsis })
      Image(this.newsData.cover)
        .objectFit(ImageFit.Cover)
        .borderRadius(Const.NewsGrid_IMAGE_BORDER_RADIUS)
    }
    .alignItems(HorizontalAlign.Start)
    .onClick(() => {
      router.pushUrl({
        url: 'pages/WebView', // 目标url
        params: {
          url: this.newsData.mobileUrl
        }
      }, router.RouterMode.Standard, (err) => {
        if (err) {
          console.error(`Invoke pushUrl failed, code is ${err.code}, message is ${err.message}`);
          return;
        }
        console.info('Invoke pushUrl succeeded.');
      });
    }

    )
  }
}

发现没有,核心逻辑就是下面这几行,我们绑定点击事件 onClick,传入 mobileUrl参数,跳转到 webview 页面,加载出移动端具体新闻页面。

 router.pushUrl({
    url: 'pages/WebView', // 目标url
    params: {
      url: this.newsData.mobileUrl
    }
  }, router.RouterMode.Standard, (err) => {
    if (err) {
      console.error(`Invoke pushUrl failed, code is ${err.code}, message is ${err.message}`);
      return;
    }
    console.info('Invoke pushUrl succeeded.');
  });
}

5.webview 组件

Web组件用于在应用程序中显示Web页面内容,为开发者提供页面加载、页面交互、页面调试等能力。

  • 页面加载:Web组件提供基础的前端页面加载的能力,包括加载网络页面、本地页面、Html格式文本数据。
  • 页面交互:Web组件提供丰富的页面交互的方式,包括:设置前端页面深色模式,新窗口中加载页面,位置权限管理,Cookie管理,应用侧使用前端页面JavaScript等能力。
  • 页面调试:Web组件支持使用Devtools工具调试前端页面。

注意官方文档中的示例存在错误。

Screenshot 2024-05-11 141359.png

注意,不是!不是!不是! import web_view from '@ohos.web.webview';导入webview,正确的导入为 import webview from '@ohos.web.webview';,这个错误我调试了很久,IDE连报错都没有,真的伤脑筋。

import webview from '@ohos.web.webview';
import router from '@ohos.router';
class Params {
  url: string
}
@Entry
@Component
struct WebComponent {
  webController: webview.WebviewController = new webview.WebviewController();

  @State params: object = router.getParams();


  build() {
    Column() {
      // 组件创建时,加载url
      Web({ src: this.params['url'], controller: this.webController })

    }
  }
}

逻辑十分简单,加载显示页面。

总结

  1. 采用了 TabBar 和 NewsList 组件实现了新闻列表的展示,并支持下拉刷新和上拉加载更多。
  2. 在 NewsItem 组件中实现了新闻标题、封面图和跳转 Webview 的逻辑。
  3. 在 Webview 组件中正确导入了 @ohos.web.webview 模块,加载并显示新闻详情页面。

整体来看,这是一个功能完整、交互体验良好的鸿蒙热榜 APP 应用程序,不足之处,还请大家批评指正。

参考文献

使用Web组件加载页面-Web-开发 | 华为开发者联盟 (huawei.com)

Web组件抽奖案例(ArkTS)(API9)