一、引言

在 HarmonyOS NEXT 及 5.0/API 12 + 版本的应用开发领域,深入理解和优化代码对于打造高质量应用至关重要。本文将聚焦于一个融合 WebView 交互、相机与相册功能以及本地数据处理的鸿蒙应用模块,详细剖析其技术栈、源码,并探讨优化思路与策略。

二、适用版本说明

本文所涉及的功能及代码实现基于 HarmonyOS NEXT 及 5.0/API 12 + 版本。在这些版本中,鸿蒙系统为开发者提供了丰富且强大的 API 与开发框架,使开发者能够实现如 Web 交互、相机相册调用以及本地数据处理等复杂功能,为应用开发提供了坚实的基础。


三、效果展示

                                                

四、技术栈概况

  1. 自定义模块
    • authcameraPluginMkUserSafeConstants 来自自定义的 ../../../../Index 路径。其中,auth 类似应用的 “守门员”,负责用户认证,保障应用数据安全;cameraPlugin 像是一个 “相机功能集合器”,封装了相机相关的操作;MkUser 可能是定义用户数据的 “模板”,规定了用户数据结构;SafeConstants 如同安全信息的 “储存箱”,存放安全相关常量。
    • 这些自定义模块紧密结合 HarmonyOS 相关版本的特性,满足应用特定需求。
  2. 第三方模块
    • webview 来自 @kit.ArkWeb,如同 Web 交互的 “魔法师”,通过创建和控制 WebView,实现 Web 页面的加载与交互,让应用能够展示丰富多样的 Web 内容。
    • HMRouterMgr 出自 @hadss/hmrouter,好比页面导航的 “引路人”,负责管理页面路由,轻松实现页面的跳转与返回,提升用户浏览体验。
    • cameracameraPicker 源于 @kit.CameraKit,是相机操作的 “得力助手”,提供打开相机拍照并获取结果的功能,为应用增添相机相关特性。
    • fileIo 来自 @kit.CoreFileKit,宛如文件操作的 “管家”,负责文件的输入输出,如打开、读取、关闭文件,确保应用对文件的有效管理。
    • util 出自 @kit.ArkTS,像一个装满实用工具的 “百宝箱”,包含 Base64 编码转换、文本解码等工具方法,方便开发者进行数据处理。
    • photoAccessHelper 来自 @kit.MediaLibraryKit,作为访问媒体库的 “钥匙”,用于从相册选择图片,丰富应用获取图片资源的途径。

五、核心功能源码剖析

        1、MKWeb 组件

@Component
export struct MKWeb {
    // 加载的页面地址,初始值为空字符串
    src: ResourceStr = ''  
    // 当前网页的标题,初始值为 'XX商城'
    @State title: string = 'XX商城'  
    // 从本地存储中获取顶部安全距离,初始值为 0
    @StorageProp(SafeConstants.TOP_HEIGHT) safeTop: number = 0  
    // 是否正在加载的状态,初始值为 true
    @State isLoading: boolean = true  
    // 加载进度,初始值为 0
    @State Progress: number = 0  
    // 当前页面在历史记录中的索引,初始值为 0
    @State historyCurrIndex: number = 0  
    // 当前页面在历史记录中的总长度,初始值为 0
    @State historySize: number = 0  

    // 创建一个 WebviewController 实例,用于控制 WebView
    controller = new webview.WebviewController()  

    /**
     * 回到web容器的上一个页面
     */
    webBack() {
        // 如果当前页面在历史记录中有前一个页面,则返回上一个页面
        if (this.historyCurrIndex > 0) {
            this.controller.backward()
        } else {
            // 否则,关闭当前页面
            HMRouterMgr.pop()
        }
    }

    /**
     * 回到上一个页面
     */
    webClose() {
        // 关闭当前页面
        HMRouterMgr.pop()
    }

    /*
     *
     * h5调用原生程序功能
     * */
    webInit() {
        // 注册 JavaScript 代理,允许 H5 调用原生功能
        this.controller.registerJavaScriptProxy({
            // 查询当前用户信息
            queryUser: (): MkUser => auth.getUser(),
            // 移除当前用户信息
            removeUser: (): void => auth.removeUser(),
            // 更新当前用户信息
            updateUser: (u: MkUser): void => auth.saveUser(u),
            // 调用相机拍照并返回照片路径
            pickerCamera: (): Promise<string> => cameraPlugin.pickerCamera(),
            // 调用相册选择照片并返回照片路径
            pickerPhoto: (): Promise<string> => cameraPlugin.pickerCamera()
        },'mk', [
            'queryUser',
           'removeUser',
            'updateUser',
            'pickerCamera',
            'pickerPhoto'
        ])
    }

    // 使用 @Builder 装饰器定义一个菜单构建器
    @Builder
    MenuBuilder() {
        Menu() {
            // 添加一个菜单项,点击时刷新页面
            MenuItem({ content: '刷新一下' })
               .onClick(() => {
                    this.controller.refresh()
                })
        }
       .width(100)
       .fontColor($r('app.color.text'))
       .font({ size: 14 })
       .radius(4)
    }

    // 构建组件的 UI
    build() {
        Column() {
            /*----------------------------------导航条--------------------------------------*/
            Row() {
                Row() {
                    // 添加返回按钮,点击时调用 webBack 方法
                    Image($r("app.media.ic_public_left"))
                       .iconStyle()
                       .onClick(() => {
                            this.webBack()
                        })
                    // 添加关闭按钮,点击时调用 webClose 方法
                    Image($r('app.media.ic_public_close'))
                       .iconStyle()
                       .onClick(() => {
                            this.webClose()
                        })
                }
               .width(100)

                // 显示当前网页的标题
                Text(this.title)
                   .fontSize(16)
                   .fontWeight(500)
                   .fontColor($r('app.color.black'))
                   .layoutWeight(1)
                   .maxLines(1)
                   .textAlign(TextAlign.Center)
                   .textOverflow({ overflow: TextOverflow.MARQUEE })
                Row() {
                    Blank()
                    // 添加更多操作按钮,绑定菜单
                    Image($r('app.media.ic_public_more'))
                       .iconStyle()
                       .bindMenu(this.MenuBuilder)
                }
               .width(100)
            }
           .height(50 + this.safeTop)
           .backgroundColor($r('app.color.white'))
           .padding({ top: this.safeTop })
            /*---------------------------------堆叠布局-------------------------------------*/
            Stack({ alignContent: Alignment.Top }) {
                // 如果正在加载,显示进度条
                if (this.isLoading) {
                    Progress({ total: 100, value: this.Progress, type: ProgressType.Linear })
                       .style({ strokeWidth: 2, enableSmoothEffect: true })
                       .color($r('app.color.red'))
                       .zIndex(1)
                }
                // 添加 WebView 组件,加载指定页面
                Web({ src: this.src, controller: this.controller })
                    // 页面开始加载时,设置 isLoading 为 true
                   .onPageBegin(() => {
                        this.isLoading = true
                    })
                    // 页面加载进度变化时,更新 Progress 状态
                   .onProgressChange((res) => {
                        this.Progress = res.newProgress
                        // 如果加载完成,延迟 300 毫秒后设置 isLoading 为 false
                        if (res.newProgress == 100) {
                            animateTo({ duration: 300, delay: 100 }, () => {
                                this.isLoading = false
                            })
                        }
                    })
                    // 页面加载完成时,不执行任何操作
                   .onPageEnd(() => { })
                    // 刷新历史记录时,更新当前页面的历史记录索引和总长度
                   .onRefreshAccessedHistory(() => {
                        const history = this.controller.getBackForwardEntries()
                        this.historyCurrIndex = history.currentIndex
                        this.historySize = history.size
                    })
                    // 接收到页面标题时,更新 title 状态
                   .onTitleReceive((res) => {
                        this.title = res.title
                    })
                    // 页面显示时,初始化 WebView
                   .onAppear(() => {
                        this.webInit()
                    })
            }
           .width('100%')
           .layoutWeight(1)
        }
       .width('100%')
       .height('100%')
       .backgroundColor($r('app.color.under'))
    }
}

// 扩展 Image 组件,定义图标样式
@Extend(Image)
function iconStyle() {
   .width(24)
   .aspectRatio(1)
   .fillColor($r('app.color.text'))
   .margin(13)
}

MKWeb 组件是整个应用 Web 交互的核心。通过各种属性如 srctitleisLoading 等,精确控制 WebView 的状态与展示内容。webBackwebClose 方法处理页面的返回与关闭逻辑,webInit 方法搭建起 H5 页面与原生功能交互的桥梁,通过注册 JavaScript 代理,使得 H5 页面能够调用如用户信息查询、相机相册操作等原生功能。MenuBuilder 构建了一个简单的刷新菜单,增强用户交互体验。在 UI 构建部分,通过合理运用布局组件,实现了导航条、加载进度条以及 WebView 的有序展示。

        2、CameraPlugin 类

class CameraPlugin {
    async pickerCamera() {
        // 1. 打开相机后置摄像头得到拍照结果集
        const pickerProfile: cameraPicker.PickerProfile = {
            // 后置摄像头
            cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK
        };
        // 打开相机
        const pickerResult: cameraPicker.PickerResult = await cameraPicker.pick(getContext(),
            // 只允许选择图片
            [cameraPicker.PickerMediaType.PHOTO], pickerProfile);

        // 2. 根据结果集的URI属性同步打开文件
        const file = fileIo.openSync(pickerResult.resultUri)
        // 3. 同步读取文件的详情信息
        const stat = fileIo.statSync(file.fd)
        // 4. 定义缓冲区用于保存读取的文件
        const buffer = new ArrayBuffer(stat.size)
        // 5. 开始同步读取内容到缓冲区
        fileIo.readSync(file.fd, buffer)
        // 6. 读取完毕后关闭文件流
        fileIo.closeSync(file)

        // 7. 借助util工具方法把读取的文件流转成base64编码的字符串
        const helper = new util.Base64Helper()
        // 8. 把base64编码的字符串打印出来
        const str = helper.encodeToStringSync(new Uint8Array(buffer))
        // 9. 打印日志
        console.log('mk - logger', 'pickerCamera', str)
        return str
    }
}

export const cameraPlugin = new CameraPlugin()

CameraPlugin 类专注于实现相机拍照功能。首先配置相机使用后置摄像头并获取拍照结果集,然后通过文件操作相关模块,将拍摄的照片文件读取并转换为 Base64 编码字符串,方便在 Web 页面中使用,同时通过日志记录操作结果。

        3、PhotoPlugin 类

class PhotoPlugin {
    async pickPhoto() {
        // 1. 打开相册选择图片
        let PhotoSelectOptions = new photoAccessHelper.PhotoSelectOptions()
        // 设置图片类型
        PhotoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE
        // 设置最多可选图片数量
        PhotoSelectOptions.maxSelectNumber = 1
        // 设置是否显示原图
        let photoPicker = new photoAccessHelper.PhotoViewPicker()
        // 调用相册选择图片
        const res = await photoPicker.select(PhotoSelectOptions)

        // 2. 文件操作
        // 2.1 获取照片的uri地址
        const uri = res.photoUris[0]
        // 2.2 根据uri同步打开文件
        const file = fileIo.openSync(uri)
        // 2.3 同步获取文件的详细信息
        const stat = fileIo.statSync(file.fd)
        // 2.4 创建缓冲区存储读取的文件流
        const buffer = new ArrayBuffer(stat.size)
        // 2.5 开始同步读取文件流到缓冲区
        fileIo.readSync(file.fd, buffer)
        // 2.6 关闭文件流
        fileIo.closeSync(file)

        // 3. 转成base64编码的字符串
        const helper = new util.Base64Helper()
        const str = helper.encodeToStringSync(new Uint8Array(buffer))
        console.log('mk - logger', 'photoPlugin - str', str)

        return str
    }
}

export const photoPlugin = new PhotoPlugin()

PhotoPlugin 类负责从相册选择图片并进行处理。通过设置相册选择选项,调用相册选择图片,后续同样进行文件读取与 Base64 编码转换操作,与 CameraPlugin 类在文件处理部分存在重复代码。

        4、LocationPlugin 类

import { util } from '@kit.ArkTS'

// 1. 定义读取的本地数据的数据类型(AreaDataItem)
export interface AreaDataItem {
    code: string
    name: string
    areaList: AreaDataItem[]
}

// 2. 定义输出数据的数据类型(AreaColumns)
export interface AreaColumns {
    province_list: Record<number, string>
    city_list: Record<number, string>
    county_list: Record<number, string>
}

class LocationPlugin {
    async getAreaColumns() {
        // 1. 定义对象用于存储转换后的数据
        const areaColumns: AreaColumns = {
            province_list: {},
            city_list: {},
            county_list: {}
        }

        try {
            // 2. 读取rawfile目录下的本地文件
            const unit8Array = getContext().resourceManager.getRawFileContentSync('area.json')
            // 3. 将读取的字节数组转成字符串
            const decoder = new util.TextDecoder()
            const resStr = decoder.decodeToString(unit8Array)
            // 4.将读取的Json字符串转成对象数组
            const areaData = JSON.parse(resStr) as AreaDataItem[]
            // 5. 遍历处理数据
            // 5.1 省转换
            areaData.forEach((province) => {
                areaColumns.province_list[Number(province.code)] = province.name
                // 5.2 市转换
                province.areaList.forEach((city) => {
                    areaColumns.city_list[Number(city.code)] = province.name
                    // 5.3 区转换
                    city.areaList.forEach((county) => {
                        areaColumns.county_list[Number(county.code)] = county.name
                    })
                })
            })
            // 6. 返回数据
            AlertDialog.show({ message: JSON.stringify(areaColumns, null, 4) })
            return areaColumns
        } catch (e) {
            return areaColumns
        }
    }
}

export const locationPlugin = new LocationPlugin()

LocationPlugin 类主要处理本地行政区划数据。先定义好输入输出数据类型,然后读取本地 area.json 文件,将字节数组转换为字符串并解析为 JSON 对象数组,经过遍历处理,将数据转换为特定格式后返回,同时在捕获到异常时也返回初始化的对象。

六、代码优化思路

  1. 代码重复问题:CameraPlugin 和 PhotoPlugin 类在文件读取与 Base64 编码转换部分的代码高度重复,这不仅增加了代码量,还不利于维护。可以将这部分重复代码提取成公共方法,提高代码复用性。
  2. 错误处理缺失:在文件读取、相机和相册操作过程中,缺乏对可能出现错误的处理。如相机或相册打开失败、文件读取失败等情况,应添加详细错误处理逻辑,向用户提供友好提示,提升应用稳定性与用户体验。
  3. 配置灵活性问题:当前代码中部分配置为硬编码,如 WebView 加载的页面地址、相机使用后置摄像头等。这种方式在不同场景复用代码时不够灵活,可考虑将这些配置提取到配置文件或通过参数传递,增强代码通用性。

七、优化策略与实现

   1、解决代码重复问题

  • 提取公共方法
class FileOperationUtil {
    static async readFileAndEncodeToBase64(uri) {
        try {
            const file = fileIo.openSync(uri);
            const stat = fileIo.statSync(file.fd);
            const buffer = new ArrayBuffer(stat.size);
            fileIo.readSync(file.fd, buffer);
            fileIo.closeSync(file);

            const helper = new util.Base64Helper();
            return helper.encodeToStringSync(new Uint8Array(buffer));
        } catch (error) {
                console.error (' 文件读取与编码过程中出现错误:', error);return null;
                }
            }
        }
  • 修改原有类
class CameraPlugin {
    async pickerCamera() {
        const pickerProfile: cameraPicker.PickerProfile = {
            cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK
        };
        const pickerResult: cameraPicker.PickerResult = await cameraPicker.pick(getContext(),
            [cameraPicker.PickerMediaType.PHOTO], pickerProfile);

        const base64Str = await FileOperationUtil.readFileAndEncodeToBase64(pickerResult.resultUri);
        if (base64Str) {
            console.log('mk - logger', 'pickerCamera', base64Str);
        }
        return base64Str;
    }
}

class PhotoPlugin {
    async pickPhoto() {
        let PhotoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
        PhotoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
        PhotoSelectOptions.maxSelectNumber = 1;
        let photoPicker = new photoAccessHelper.PhotoViewPicker();
        const res = await photoPicker.select(PhotoSelectOptions);

        const base64Str = await FileOperationUtil.readFileAndEncodeToBase64(res.photoUris[0]);
        if (base64Str) {
            console.log('mk - logger', 'photoPlugin - str', base64Str);
        }
        return base64Str;
    }
}

通过创建FileOperationUtil类并将文件读取与 Base64 编码转换代码封装为静态方法,CameraPluginPhotoPlugin类调用该方法,简化了代码结构,提高了代码复用性。

   2、增强错误处理机制

  • 相机与相册操作
class CameraPlugin {
    async pickerCamera() {
        try {
            const pickerProfile: cameraPicker.PickerProfile = {
                cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK
            };
            const pickerResult: cameraPicker.PickerResult = await cameraPicker.pick(getContext(),
                [cameraPicker.PickerMediaType.PHOTO], pickerProfile);

            const base64Str = await FileOperationUtil.readFileAndEncodeToBase64(pickerResult.resultUri);
            if (base64Str) {
                console.log('mk - logger', 'pickerCamera', base64Str);
            }
            return base64Str;
        } catch (error) {
            console.error('相机操作失败:', error);
            CustomToast.show('相机操作失败,请检查相机权限或设备状态');
            return null;
        }
    }
}

class PhotoPlugin {
    async pickPhoto() {
        try {
            let PhotoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
            PhotoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
            PhotoSelectOptions.maxSelectNumber = 1;
            let photoPicker = new photoAccessHelper.PhotoViewPicker();
            const res = await photoPicker.select(PhotoSelectOptions);

            const base64Str = await FileOperationUtil.readFileAndEncodeToBase64(res.photoUris[0]);
            if (base64Str) {
                console.log('mk - logger', 'photoPlugin - str', base64Str);
            }
            return base64Str;
        } catch (error) {
            console.error('相册操作失败:', error);
            CustomToast.show('相册操作失败,请检查相册权限或重新尝试');
            return null;
        }
    }
}

CameraPluginPhotoPlugin类的方法中添加try - catch块,捕获操作过程中的异常,并通过自定义弹窗组件CustomToast向用户展示友好的错误提示信息,增强了应用的健壮性和用户体验。

  • 文件操作FileOperationUtil类的readFileAndEncodeToBase64方法已添加错误处理逻辑,当文件读取或编码过程出现错误时,记录错误信息并返回null,上层调用类可以根据返回值进行相应处理。

3、提升配置灵活性

  • WebView 配置
@Component
export struct MKWeb {
    @Prop src: ResourceStr;
    @State title: string = 'XX商城';
    @StorageProp(SafeConstants.TOP_HEIGHT) safeTop: number = 0;
    @State isLoading: boolean = true;
    @State Progress: number = 0;
    @State historyCurrIndex: number = 0;
    @State historySize: number = 0;

    controller = new webview.WebviewController();

    // 其他方法...

    build() {
        Column() {
            // 导航条部分...
            Stack({ alignContent: Alignment.Top }) {
                if (this.isLoading) {
                    Progress({ total: 100, value: this.Progress, type: ProgressType.Linear })
                       .style({ strokeWidth: 2, enableSmoothEffect: true })
                       .color($r('app.color.red'))
                       .zIndex(1);
                }
                Web({ src: this.src, controller: this.controller })
                   .onPageBegin(() => {
                        this.isLoading = true;
                    })
                   .onProgressChange((res) => {
                        this.Progress = res.newProgress;
                        if (res.newProgress == 100) {
                            animateTo({ duration: 300, delay: 100 }, () => {
                                this.isLoading = false;
                            });
                        }
                    })
                   .onPageEnd(() => { })
                   .onRefreshAccessedHistory(() => {
                        const history = this.controller.getBackForwardEntries();
                        this.historyCurrIndex = history.currentIndex;
                        this.historySize = history.size;
                    })
                   .onTitleReceive((res) => {
                        this.title = res.title;
                    })
                   .onAppear(() => {
                        this.webInit();
                    });
            }
           .width('100%')
           .layoutWeight(1);
        }
       .width('100%')
       .height('100%')
       .backgroundColor($r('app.color.under'));
    }
}

@Entry
@Component
struct App {
    build() {
        Column() {
            MKWeb({ src: 'https://example.com' });
        }
    }
}

MKWeb组件中,将src属性改为通过@Prop装饰器接收页面地址,在使用MKWeb组件时可灵活传入不同的页面地址,提高了 WebView 加载页面的配置灵活性。

  • 相机配置
class CameraPlugin {
    async pickerCamera(cameraPosition = camera.CameraPosition.CAMERA_POSITION_BACK) {
        try {
            const pickerProfile: cameraPicker.PickerProfile = {
                cameraPosition: cameraPosition
            };
            const pickerResult: cameraPicker.PickerResult = await cameraPicker.pick(getContext(),
                [cameraPicker.PickerMediaType.PHOTO], pickerProfile);

            const base64Str = await FileOperationUtil.readFileAndEncodeToBase64(pickerResult.resultUri);
            if (base64Str) {
                console.log('mk - logger', 'pickerCamera', base64Str);
            }
            return base64Str;
        } catch (error) {
            console.error('相机操作失败:', error);
            CustomToast.show('相机操作失败,请检查相机权限或设备状态');
            return null;
        }
    }
}

// 在MKWeb的webInit方法中调用时,可以根据需求传入不同的摄像头位置参数
webInit() {
    this.controller.registerJavaScriptProxy({
        queryUser: (): MkUser => auth.getUser(),
        removeUser: (): void => auth.removeUser(),
        updateUser: (u: MkUser): void => auth.saveUser(u),
        pickerCamera: (): Promise<string> => cameraPlugin.pickerCamera(camera.CameraPosition.CAMERA_POSITION_FRONT),
        pickerPhoto: (): Promise<string> => cameraPlugin.pickerCamera()
    },'mk', [
        'queryUser',
       'removeUser',
        'updateUser',
        'pickerCamera',
        'pickerPhoto'
    ]);
}

CameraPlugin类的pickerCamera方法中,通过参数cameraPosition来选择摄像头位置,默认使用后置摄像头,在MKWebwebInit方法中调用时可根据需求传入不同的摄像头位置参数,增加了相机配置的灵活性。

八、总结

通过对 HarmonyOS 应用 Web 交互功能代码的深入分析与优化,我们成功解决了代码重复、错误处理缺失以及配置灵活性不足等问题。在 HarmonyOS NEXT 及 5.0/API 12 + 版本的开发环境下,这些优化提升了代码质量和可维护性,为应用在不同场景下的复用和扩展奠定了坚实基础。

在实际开发中,持续关注代码优化是打造稳定、高效且用户体验良好的鸿蒙应用的关键。后续我会在博客中持续分享更多鸿蒙应用开发的优化技巧与实践经验,欢迎大家关注。如果对于本文的优化内容,你有任何疑问或建议,欢迎在评论区留言交流。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐