DEV Community

linzhongxue
linzhongxue

Posted on

Practical Guide to Developing a News App Based on HarmonyOS Next

Practical Guide to Developing a News App Based on HarmonyOS Next

1. Project Overview and Environment Setup

With the release of HarmonyOS Next, developers can leverage its enhanced distributed capabilities and improved toolchain to build cross-device application experiences. This tutorial will guide you through developing a news app, covering core modules such as news listing, detailed viewing, and bookmarking functionality.

First, ensure your development environment is ready:

  1. Install the latest version of DevEco Studio (recommended 4.1 or above).
  2. When creating a project, select "Application" → "Empty Ability."
  3. Configure AppGallery Connect services (details will be covered later).

The project structure primarily consists of the following key components:

  • pages: Contains page code.
  • resources: Stores application resource files.
  • entryability: Application entry point.
  • model: Data model layer.

2. AppGallery Connect Service Integration

AppGallery Connect provides robust backend services for HarmonyOS applications. First, complete the following configurations:

  1. Create a project in the AppGallery Connect console.
  2. Enable Authentication Service (Auth Service) and Cloud Database (Cloud DB).
  3. Download the configuration file agconnect-services.json and place it in the project's entry directory.

Add dependencies in build.gradle:

// entry/build.gradle  
dependencies {  
    implementation 'io.agconnect.agconnect-core-harmony:agconnect-core:1.6.0.300'  
    implementation 'io.agconnect.agconnect-auth-harmony:agconnect-auth:1.6.0.300'  
    implementation 'io.agconnect.agconnect-clouddb-harmony:agconnect-clouddb:1.6.0.300'  
}  
Enter fullscreen mode Exit fullscreen mode

Initialization code:

// entryability/EntryAbility.ts  
import agconnect from '@hw-agconnect/api-ohos';  
import '@hw-agconnect/core-ohos';  
import '@hw-agconnect/auth-ohos';  
import '@hw-agconnect/clouddb-ohos';  

onCreate() {  
    agconnect.instance().init(this.context);  
    // Initialize Cloud Database  
    this.initCloudDB();  
}  

async initCloudDB() {  
    try {  
        const cloudDBZoneConfig = new cloud.CloudDBZoneConfig('NewsDB');  
        this.cloudDBZone = await cloud.CloudDBZone.open(cloudDBZoneConfig);  
    } catch (err) {  
        console.error('CloudDB init failed: ' + JSON.stringify(err));  
    }  
}  
Enter fullscreen mode Exit fullscreen mode

3. News Data Model Design

Define the news data object type for local and cloud storage. Create News.ts in the model directory:

// model/News.ts  
import { cloud } from '@hw-agconnect/clouddb-ohos';  

export class News {  
    // Use decorators to define Cloud DB fields  
    @cloud.Field()  
    id: string = '';  

    @cloud.Field()  
    title: string = '';  

    @cloud.Field()  
    content: string = '';  

    @cloud.Field()  
    author: string = '';  

    @cloud.Field()  
    publishTime: number = 0;  

    @cloud.Field()  
    category: string = '';  

    @cloud.Field()  
    imageUrl: string = '';  

    @cloud.Field()  
    isFavorite: boolean = false;  

    constructor() {  
        // Set object type name (must match the Cloud DB object type)  
        cloud.ObjectType.register(this, 'News');  
    }  
}  
Enter fullscreen mode Exit fullscreen mode

After creating the corresponding object type in the Cloud DB console, you can perform CRUD operations on the data.

4. News Listing Page Development

The news listing is the app's core page. We'll use the List component to display news data with pull-to-refresh and infinite scroll functionality.

// pages/NewsListPage.ets  
import { News } from '../model/News';  
import { cloud } from '@hw-agconnect/clouddb-ohos';  

@Entry  
@Component  
struct NewsListPage {  
    @State newsList: Array<News> = [];  
    @State isLoading: boolean = false;  
    @State isRefreshing: boolean = false;  
    private pageSize: number = 10;  
    private pageIndex: number = 0;  

    build() {  
        Column() {  
            // Header  
            Row() {  
                Text('Top News')  
                    .fontSize(24)  
                    .fontWeight(FontWeight.Bold)  
                Blank()  
                Image($r('app.media.ic_search'))  
                    .width(30)  
                    .height(30)  
                    .margin({ right: 15 })  
            }  
            .width('100%')  
            .padding(15)  
            .backgroundColor('#FF4D4F')  

            // News list  
            List({ space: 10 }) {  
                ForEach(this.newsList, (item: News) => {  
                    ListItem() {  
                        NewsItem({ news: item })  
                    }  
                }, (item: News) => item.id)  
            }  
            .width('100%')  
            .layoutWeight(1)  
            .onScrollIndex((start: number) => {  
                // Load more when scrolling to the bottom  
                if (start >= this.newsList.length - 3 && !this.isLoading) {  
                    this.loadMoreNews();  
                }  
            })  
            .scrollBar(BarState.Off)  
        }  
        .width('100%')  
        .height('100%')  
        .onAppear(() => {  
            this.refreshNews();  
        })  
    }  

    // Pull-to-refresh  
    async refreshNews() {  
        this.isRefreshing = true;  
        this.pageIndex = 0;  
        try {  
            const query = cloud.CloudDBZoneQuery.where(News).orderByDesc('publishTime').limit(this.pageSize);  
            const result = await this.cloudDBZone.executeQuery(query, News);  
            this.newsList = result;  
        } catch (err) {  
            console.error('Refresh news failed: ' + JSON.stringify(err));  
        }  
        this.isRefreshing = false;  
    }  

    // Load more  
    async loadMoreNews() {  
        this.isLoading = true;  
        this.pageIndex++;  
        try {  
            const query = cloud.CloudDBZoneQuery.where(News)  
                .orderByDesc('publishTime')  
                .limit(this.pageSize)  
                .offset(this.pageIndex * this.pageSize);  
            const result = await this.cloudDBZone.executeQuery(query, News);  
            this.newsList = this.newsList.concat(result);  
        } catch (err) {  
            console.error('Load more news failed: ' + JSON.stringify(err));  
        }  
        this.isLoading = false;  
    }  
}  

// News item component  
@Component  
struct NewsItem {  
    @Prop news: News;  

    build() {  
        Column() {  
            // News image  
            Image(this.news.imageUrl)  
                .width('100%')  
                .height(200)  
                .objectFit(ImageFit.Cover)  
                .borderRadius(8)  

            // Title and summary  
            Column() {  
                Text(this.news.title)  
                    .fontSize(18)  
                    .fontWeight(FontWeight.Bold)  
                    .margin({ bottom: 5 })  
                Text(this.news.content.substring(0, 50) + '...')  
                    .fontSize(14)  
                    .fontColor('#666')  
            }  
            .padding(10)  

            // Footer info  
            Row() {  
                Text(this.news.author)  
                    .fontSize(12)  
                    .fontColor('#999')  
                Blank()  
                Text(this.formatTime(this.news.publishTime))  
                    .fontSize(12)  
                    .fontColor('#999')  
                Image(this.news.isFavorite ? $r('app.media.ic_favorite') : $r('app.media.ic_favorite_border'))  
                    .width(20)  
                    .height(20)  
                    .margin({ left: 10 })  
                    .onClick(() => {  
                        this.toggleFavorite();  
                    })  
            }  
            .width('100%')  
            .padding({ left: 10, right: 10, bottom: 10 })  
        }  
        .width('95%')  
        .margin({ top: 5, bottom: 5 })  
        .backgroundColor('#FFF')  
        .borderRadius(8)  
        .shadow({ radius: 4, color: '#10000000', offsetX: 0, offsetY: 2 })  
        .onClick(() => {  
            router.pushUrl({ url: 'pages/NewsDetailPage', params: { newsId: this.news.id } });  
        })  
    }  

    // Format timestamp  
    private formatTime(timestamp: number): string {  
        const date = new Date(timestamp);  
        return `${date.getMonth() + 1}/${date.getDate()}`;  
    }  

    // Toggle bookmark  
    private async toggleFavorite() {  
        try {  
            this.news.isFavorite = !this.news.isFavorite;  
            await this.cloudDBZone.executeUpsert([this.news]);  
        } catch (err) {  
            console.error('Toggle favorite failed: ' + JSON.stringify(err));  
        }  
    }  
}  
Enter fullscreen mode Exit fullscreen mode

5. News Detail Page Implementation

The news detail page displays the full content and supports bookmarking.

// pages/NewsDetailPage.ets  
import { News } from '../model/News';  

@Entry  
@Component  
struct NewsDetailPage {  
    @State news: News = new News();  
    private newsId: string = '';  

    onPageShow() {  
        this.newsId = router.getParams()?.newsId;  
        this.loadNewsDetail();  
    }  

    build() {  
        Column() {  
            // Back button and title  
            Row() {  
                Image($r('app.media.ic_back'))  
                    .width(24)  
                    .height(24)  
                    .onClick(() => {  
                        router.back();  
                    })  
                Text('News Detail')  
                    .fontSize(20)  
                    .fontWeight(FontWeight.Bold)  
                    .margin({ left: 15 })  
                Blank()  
                Image(this.news.isFavorite ? $r('app.media.ic_favorite') : $r('app.media.ic_favorite_border'))  
                    .width(24)  
                    .height(24)  
                    .onClick(() => {  
                        this.toggleFavorite();  
                    })  
            }  
            .width('100%')  
            .padding(15)  
            .backgroundColor('#FF4D4F')  

            // Content area  
            Scroll() {  
                Column() {  
                    // Title and author info  
                    Text(this.news.title)  
                        .fontSize(22)  
                        .fontWeight(FontWeight.Bold)  
                        .margin({ bottom: 10 })  
                    Row() {  
                        Text(`Author: ${this.news.author}`)  
                            .fontSize(14)  
                            .fontColor('#666')  
                        Text(`Published: ${this.formatTime(this.news.publishTime)}`)  
                            .fontSize(14)  
                            .fontColor('#666')  
                            .margin({ left: 15 })  
                    }  
                    .margin({ bottom: 15 })  

                    // News image  
                    Image(this.news.imageUrl)  
                        .width('100%')  
                        .height(250)  
                        .objectFit(ImageFit.Cover)  
                        .margin({ bottom: 20 })  

                    // News content  
                    Text(this.news.content)  
                        .fontSize(16)  
                        .lineHeight(26)  
                }  
                .padding(20)  
            }  
            .scrollBar(BarState.Off)  
            .layoutWeight(1)  
        }  
        .width('100%')  
        .height('100%')  
    }  

    // Load news details  
    private async loadNewsDetail() {  
        try {  
            const query = cloud.CloudDBZoneQuery.where(News).equalTo('id', this.newsId);  
            const result = await this.cloudDBZone.executeQuery(query, News);  
            if (result && result.length > 0) {  
                this.news = result[0];  
            }  
        } catch (err) {  
            console.error('Load news detail failed: ' + JSON.stringify(err));  
        }  
    }  

    // Toggle bookmark  
    private async toggleFavorite() {  
        try {  
            this.news.isFavorite = !this.news.isFavorite;  
            await this.cloudDBZone.executeUpsert([this.news]);  
        } catch (err) {  
            console.error('Toggle favorite failed: ' + JSON.stringify(err));  
        }  
    }  

    // Format timestamp  
    private formatTime(timestamp: number): string {  
        const date = new Date(timestamp);  
        return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;  
    }  
}  
Enter fullscreen mode Exit fullscreen mode

6. User Authentication and Bookmarking

To enable bookmarking, implement user authentication using AppGallery Connect Auth Service.

// utils/AuthUtil.ts  
import { agconnect } from '@hw-agconnect/api-ohos';  
import '@hw-agconnect/auth-ohos';  

export class AuthUtil {  
    // Anonymous login  
    static async anonymousLogin(): Promise<boolean> {  
        try {  
            const user = await agconnect.auth().signInAnonymously();  
            return user != null;  
        } catch (err) {  
            console.error('Anonymous login failed: ' + JSON.stringify(err));  
            return false;  
        }  
    }  

    // Get current user ID  
    static getCurrentUserId(): string | null {  
        const user = agconnect.auth().currentUser;  
        return user ? user.uid : null;  
    }  

    // Logout  
    static async logout(): Promise<void> {  
        try {  
            await agconnect.auth().signOut();  
        } catch (err) {  
            console.error('Logout failed: ' + JSON.stringify(err));  
        }  
    }  
}  
Enter fullscreen mode Exit fullscreen mode

Modify the News model to include user association:

// model/News.ts  
export class News {  
    // ...existing fields  

    @cloud.Field()  
    userId: string = ''; // Associated user ID  

    // ...other code  
}  
Enter fullscreen mode Exit fullscreen mode

Update bookmarking to check login status:

// In NewsItem component, modify toggleFavorite  
private async toggleFavorite() {  
    if (!AuthUtil.getCurrentUserId()) {  
        const isLogin = await AuthUtil.anonymousLogin();  
        if (!isLogin) {  
            prompt.showToast({ message: 'Please log in first' });  
            return;  
        }  
    }  

    try {  
        this.news.isFavorite = !this.news.isFavorite;  
        this.news.userId = AuthUtil.getCurrentUserId() || '';  
        await this.cloudDBZone.executeUpsert([this.news]);  
    } catch (err) {  
        console.error('Toggle favorite failed: ' + JSON.stringify(err));  
    }  
}  
Enter fullscreen mode Exit fullscreen mode

7. App Packaging and Release

After development, package and publish the app to AppGallery:

  1. In DevEco Studio, select Build → Generate Key and CSR to create a signing certificate.
  2. Configure signing information in build-profile.json5.
  3. Select Build → Build HAP(s)/APP(s) → Build APP to generate the release package.
  4. Log in to the AppGallery Connect console, navigate to "My Apps," and create a new app.
  5. Upload the generated APP file, fill in app details, and submit for review.

8. Advanced Feature Suggestions

After completing the basics, consider adding:

  1. Category Filtering: Add category tabs for filtering news.
  2. Comments: Implement comment functionality using Cloud DB.
  3. Offline Reading: Use local storage for offline access.
  4. Push Notifications: Integrate AppGallery Connect Push Kit for breaking news alerts.
  5. Multi-Device Sync: Leverage HarmonyOS distributed capabilities for seamless device switching.

This tutorial provides a complete guide to developing a HarmonyOS Next-based news app, from setup to implementation. Use it as a foundation to build more sophisticated applications.

Top comments (0)