标签 鸿蒙 下的文章

利益相关声明:作者与文中产品有直接的利益相关(开发者、自家产品等)

Matrix 首页推荐 

Matrix 是少数派的写作社区,我们主张分享真实的产品体验,有实用价值的经验与思考。我们会不定期挑选 Matrix 最优质的文章,展示来自用户的最真实的体验和观点。 

文章代表作者个人观点,少数派仅对标题和排版略作修改。


当「英语四六级」遇上「英文菜单」

大家好,我是开发者 Pyacark。故事的起点很俗套,但也很真实。作为一个在英语考试中身经百战的选手,词汇量虽然马马虎虎,但也不是两眼一抹黑。但是每次出国旅行,走进当地餐厅,打开菜单的那一刻——却总是充满尴尬

那些单词我都认识字母,组合在一起却仿佛天书。没有 abandon,没有复杂的从句,只有 Glazed(上釉的?不,是蜜汁)、Tart(酸的?不,是塔)、Poached(偷猎?不,是水煮)。那些饭是盲点的,那份尴尬,让我意识到一个问题:我们的英语学习,往往是为了「考试」,而不是为了「生活」。

即使背了再多单词,当生活场景具体到「吃」这件事上时,我们依然可能是失语者

就是这个念头,让我决定开发一款专注于垂直细分领域的单词 App —— 「上菜单词」。它的初衷特别简单:让每一个成年旅行者,都能自信地看懂菜单,体面地完成点餐。

为什么背单词不能像「抽卡」一样快乐?

在立项之初,我一直在思考一个问题:为什么背单词如此反人性?从心理学角度看,背单词是一种极端的「延迟满足」(Delayed Gratification)。你今天背的单词,可能要三个月后的考试,甚至三年后的旅行中才能用上。这种反馈链路太长,大脑很难产生持续的多巴胺。

反观现在的游戏,为什么我们沉迷于「抽卡」、「开箱」?因为那是「即时满足」(Instant Gratification)。手指一点,金光一闪,获得感瞬间炸裂。哪怕是垃圾蓝天白云,你也会期待下一发的出货。

作为一个「抽卡星人」,我产生了一个离经叛道的脑洞:如果把每一个枯燥的单词,都变成一张值得收藏的精美卡牌呢? 如果复习单词不再是痛苦的「任务」,而是为了积攒运气去「多连抽」呢?于是,我摒弃了传统单词 App 那种一本正经的列表形式,将「上菜单词」设计成了一台「美食盲盒机」

机制拆解:用「收集癖」对抗「遗忘曲线」

在「上菜单词」中,核心逻辑非常简单,形成了一个正向的游戏化闭环:

  1. 学习即赚币: 你不再是枯燥地记忆,而是在赚取「餐券」。通过日常的签到、新词学习、旧词复习,系统会奖励你通用的抽卡货币。
  2. 单词即卡牌: 当你积累了足够的餐券,就可以去卡池里进行「五连抽」。看着卡包撕开,光芒四射,爆出一张张设计精美的 3D 美食单词卡——比如一张  Eggs Benedict(班尼迪克蛋),或者一张 Molecular Gastronomy(分子料理)。
  3. 图鉴即词典: 所有的卡牌都会收录进你的「美食图鉴」。为了点亮那个灰色的图标,为了集齐一套「法式甜点」卡组,你会不自觉地想要多背几个单词,多复习几轮。

我希望这不仅仅是一个吸引眼球的噱头。我们在谈论记忆方法时,常提到艾宾浩斯遗忘曲线。而在「上菜单词」里,我试图用人类本能的「收集癖」去对抗「爱遗忘」的本能。

为了获得抽卡机会,用户必须保持高频的复习;为了集齐图鉴,用户会主动增加接触单词的频率。当「背单词」变成了「集卡片」,枯燥的重复就变成了期待的铺垫

始于颜值,终于科学

当然,作为一款工具类 App,光有好皮囊是不够的。在「抽卡」的娱乐外壳之下,内核依然是严肃的学习逻辑。为了保证记忆效率,我在应用中引入了 FSRS (Free Spaced Repetition Scheduler) 间隔重复算法。

不同于传统的艾宾浩斯算法,FSRS 能更精准地捕捉你对每个单词的遗忘临界点。系统会根据你的每一次反馈(认识/模糊/忘记),动态调整下一次复习的时间。

简单的词(比如 Beef),可能几天后才出现;困难的词(比如 Caramelized),会在你即将遗忘的前一刻跳出来「攻击」你。「抽卡」负责让你开始 ,「算法」负责帮你记住 。

少点「蹦词」:从卡牌到餐桌的实战演练

除了「抽卡」带来的收集快乐,在开发过程中,我还意识到另一个痛点:很多时候,我们背了单词,但在真实场景下依然只会尴尬地往外蹦单词。

你抽到了 Steak(牛排)这张卡,这很好。但当服务员问你 「How would you like that cooked?」或者你想表达 「酱汁请分开放」 时,光有一个名词是不够的。为了解决这个「哑巴吃货」的困境,我在 App 中加入了一个核心功能板块——「场景对话实战」

这不是那种教科书式的 「How are you? I'm fine」,而是极度垂直的餐厅生存指南。模拟了从走进餐厅、就座、点餐、特殊要求(如过敏、忌口)到结账的完整流程。

  1. 比如在咖啡馆场景: 仅仅认识 Latte 是不够的,你还需要学会如何顺滑地说出 「Iced, with oat milk, less ice, please.」(冰拿铁,换燕麦奶,少冰)。
  2. 比如在过敏场景: 系统会教你用最准确的句式确认 「Does this contain peanuts?」(这个含有花生吗?)。

如果说「抽卡」是帮你收集武器,那么「场景对话」就是带你去靶场射击

我希望「上菜单词」不仅能帮你构建一个华丽的美食词汇库,更能让你在异国他乡的餐厅里,从容自信地与服务员谈笑风生,而不是仅仅用手指着菜单说 「This, and this」。

写在最后:一场关于「吃」的小实验

「上菜单词」目前还只是一个初生者。它没有社交,没有广告,目前也是完全免费的。它更像是我作为一个开发者,对「如何快乐学习」这一命题交出的一份答卷。

我希望它能解决两个问题: 

  • 第一,解决「场景化痛点」,下次出国时,不再对着菜单两眼一抹黑;
  • 第二,解决「动力问题」,在每一次点击「抽卡」的瞬间,找回久违的学习快感。

如果你也厌倦了枯燥的列表记忆,如果你也是看到「未解锁图鉴」就手痒的强迫症患者,欢迎来试试我的这个小实验。

希望能帮你把「背单词」这件苦差事,变成一场「上菜」的快乐盛宴

上菜!上菜! 🥘✨

📱 应用信息

  • 应用名称: 上菜单词
  • 支持平台: HarmonyOS (iOS 稍晚上线)
  • 价格: 目前免费
  • 开发者: Pyacark

> 关注 少数派小红书,感受精彩数字生活 🍃

> 实用、好用的 正版软件,少数派为你呈现 🚀

    在这里插入图片描述

    摘要

    随着鸿蒙系统在手机、平板、穿戴设备以及多终端场景中的应用越来越多,UI 流畅度已经成为用户最直观、最容易感知的问题之一。
    在实际开发中,很多页面逻辑并不复杂,但依然会出现掉帧、滑动卡顿、动画不顺畅等情况,问题往往不在 CPU,而是出在 GPU 渲染压力过大 上。

    本文结合 ArkUI 实际开发经验,从页面结构、状态管理、动画、图片、列表等多个角度,系统性地讲一讲 鸿蒙系统中 GPU 渲染性能该怎么优化,并给出可以直接运行的 Demo 示例代码,帮助你在真实项目中快速落地。

    引言

    在 HarmonyOS / OpenHarmony 体系下,UI 渲染主要由 ArkUI + 系统渲染管线 + GPU 协同完成。
    理想情况下,每一帧的渲染时间要控制在 16ms 以内(60fps),一旦 GPU 在某一帧中承担了过多工作,就会直接表现为:

    • 页面滑动一卡一卡的
    • 动画有明显掉帧
    • 列表滚动不跟手
    • 设备发热、功耗升高

    尤其是在 列表页、图片多的页面、复杂动画页面 中,这些问题非常常见。

    所以,GPU 优化不是“锦上添花”,而是必须要做的基础工作

    减少无效重绘是第一优先级

    状态放对位置,比任何技巧都重要

    在 ArkUI 中,只要 @State 发生变化,就会触发组件重新构建和重新渲染。
    如果状态放得不合理,GPU 就会被迫做很多“没必要的活”。

    错误示例:一个状态刷新整个页面

    @Entry
    @Component
    struct BadPage {
      @State count: number = 0
    
      build() {
        Column() {
          Text('当前数值:' + this.count)
          Button('点击 +1')
            .onClick(() => {
              this.count++
            })
        }
      }
    }

    这里的问题是:
    整个 Page 都会随着 count 改变而刷新

    推荐做法:把状态下沉到最小组件

    @Component
    struct Counter {
      @State count: number = 0
    
      build() {
        Column() {
          Text('当前数值:' + this.count)
          Button('点击 +1')
            .onClick(() => {
              this.count++
            })
        }
      }
    }
    
    @Entry
    @Component
    struct GoodPage {
      build() {
        Column() {
          Counter()
        }
      }
    }

    这样 GPU 只需要重绘 Counter 这块区域,页面其它部分完全不受影响

    实际场景:仪表盘 / 实时数据页面

    比如你在做一个设备状态监控页面

    • 电量实时变化
    • 网络状态刷新
    • 温度数值更新

    如果所有数据都放在一个 Page 的 State 中,那 GPU 每秒都在全量刷新页面。

    更好的做法是:

    • 每一个数据块独立成组件
    • 各自维护自己的 State

    这样就能明显降低 GPU 的渲染负载。

    减少透明度和层级嵌套(Overdraw)

    opacity 是 GPU 的“隐形杀手”

    很多开发者喜欢用 opacity 做视觉效果,但实际上它非常容易触发 离屏渲染

    不推荐的写法

    Column() {
      Text('Hello HarmonyOS')
    }
    .opacity(0.5)

    推荐写法:直接用半透明颜色

    Column() {
      Text('Hello HarmonyOS')
    }
    .backgroundColor('#80FFFFFF')

    原因很简单
    opacity 会让 GPU 先在缓存中绘制,再合成到屏幕上,步骤变多了,性能自然下降。

    实际场景:弹窗、蒙层页面

    常见的弹窗结构是:

    • 半透明遮罩
    • 中间卡片

    推荐做法:

    • 遮罩用半透明色值
    • 卡片背景保持不透明
    • 避免多层 Stack 嵌套

    这样在低端设备上也能保证弹窗动画顺畅。

    图片与纹理优化

    图片尺寸不匹配,会让 GPU 白干活

    GPU 很不喜欢加载大图再缩小显示

    错误示例

    Image($r('app.media.big_image'))
      .width(100)
      .height(100)

    正确做法:准备合适尺寸资源

    Image($r('app.media.image_100'))
      .width(100)
      .height(100)

    使用缓存,避免反复解码

    Image($r('app.media.avatar'))
      .cache(true)

    这在 列表头像、商品图片 这种场景下,效果非常明显。

    实际场景:商品列表 / 相册页面

    • 列表中每一项都有图片
    • 滑动过程中频繁创建 Image

    如果没有缓存和尺寸控制,很容易出现:

    • 滑动掉帧
    • 页面发热

    动画优化:只动 transform,不动布局

    动布局动画成本非常高

    不推荐

    .animate({ duration: 300 })
    .width(this.size)

    这里会触发布局重新计算,GPU 和 CPU 都要加班。

    推荐:使用 transform

    .animate({ duration: 300 })
    .transform({
      translateX: this.offset
    })

    transform 只影响最终绘制阶段,对 GPU 更友好。

    实际场景:侧滑菜单 / 卡片动画

    • 菜单滑入滑出
    • 卡片弹出收起

    这些动画如果全用 transform,基本可以做到低端机也不卡

    列表必须使用 LazyForEach

    普通 ForEach 的问题

    ForEach(this.list, item => {
      Text(item.name)
    })

    数据一多,GPU 会直接爆炸。

    正确姿势:LazyForEach

    LazyForEach(this.list, (item) => {
      Text(item.name)
    }, item => item.id)

    只有屏幕可见的部分才会真正创建和渲染。

    实际场景:设备列表 / 日志列表

    比如:

    • 智能设备列表
    • 升级日志
    • 消息列表

    LazyForEach 基本是必选项

    完整可运行 Demo:高性能列表页面

    @Entry
    @Component
    struct GpuOptimizeDemo {
      private data: Array<{ id: number; name: string }> = []
    
      aboutToAppear() {
        for (let i = 0; i < 1000; i++) {
          this.data.push({ id: i, name: '设备 ' + i })
        }
      }
    
      build() {
        List() {
          LazyForEach(this.data, (item) => {
            ListItem() {
              Row() {
                Text(item.name)
                  .fontSize(16)
              }
              .padding(12)
            }
          }, item => item.id)
        }
      }
    }

    这个 Demo 在真机上滑动时,GPU 占用非常稳定。

    QA 环节

    Q1:GPU 优化是不是只针对低端设备?

    不是。
    高端设备只是“扛得住”,但功耗和发热依然会变高。

    Q2:opacity 一点都不能用吗?

    不是不能用,而是少用、慎用,尤其避免大面积使用。

    Q3:怎么快速定位 GPU 问题?

    • DevEco Studio 的布局和性能分析
    • 看是否有掉帧
    • 看是否存在大面积 Overdraw

    总结

    在鸿蒙系统中,GPU 渲染优化的核心思路其实很简单:

    • 状态尽量小、尽量局部
    • 少透明、少嵌套
    • 图片尺寸要对、缓存要开
    • 动画只动 transform
    • 列表一定懒加载

    这些优化手段单独看都不复杂,但一旦组合起来,页面流畅度会有非常明显的提升。

    在这里插入代码片作者:高阔

    1. 背景

    这可能是全网第一篇完整讲解鸿蒙端使用CANN部署AI模型的文章, 满满干货。

    社区作为用户交流、信息传递的核心载体,图片内容(如理财产品截图、投资经验分享配图、用户互动评论图片等)的展示质量直接影响用户的信息获取效率与平台信任感。从京东金融App社区的业务需求来看,当前用户上传图片普遍存在多样性失真问题:部分用户通过老旧设备拍摄的图片分辨率较低,部分用户为节省流量选择低画质压缩上传,还有部分截图类内容因原始来源清晰度不足导致信息模糊(如理财产品收益率数字、合同条款细节等),这些问题不仅降低了内容可读性,还可能因信息传递不清晰引发用户误解。

    京东金融App团队已完成Real-ESRGAN-General-x4v3超分辨率模型在安卓端的部署,能够针对性提升评论区、内容详情页、个人主页等核心场景的图片清晰度,从视觉体验层面优化用户留存与互动意愿。

    ESRGAN-General-x4v3模型在安卓端的部署,采用的是ONNX框架,该方案已有大量公开资料可参考,且取得显著业务成效。但鸿蒙端部署面临核心技术瓶颈:鸿蒙系统不支持ONNX框架,部署端侧AI仅能使用华为自研的CANN(Compute Architecture for Neural Networks)架构,且当前行业内缺乏基于CANN部署端侧AI的公开资料与成熟方案,全程需技术团队自主探索。接下来我会以ESRGAN-General-x4v3为例, 分享从模型转换(NPU亲和性改造)到端侧离线模型部署的全部过程。

    2. 部署前期准备

    2.1 离线模型转换

    CANN Kit当前仅支持Caffe、TensorFlow、ONNX和MindSpore模型转换为离线模型,其他格式的模型需要开发者自行转换为CANN Kit支持的模型格式。模型转换为OM离线模型,移动端AI程序直接读取离线模型进行推理。

    2.1.1 下载CANN工具

    从鸿蒙开发者官网下载 DDK-tools-5.1.1.1, 解压使用Tools下的OMG工具,将ONNX、TensorFlow模型转换为OM模型。(OMG工具位于Tools下载的tools/tools\_omg下,仅可运行在64位Linux平台上。)

    

    2.1.2 下载ESRGAN-General-x4v3模型文件

    https://aihub.qualcomm.com/compute/models/real\_esrgan\_general\_x4v3下载模型的onnx文件.

    注意: 下载链接中的a8a8的量化模型使用了高通的算子(亲测无法转换), CANN工具无法进行转换, 因此请下载float的量化模型。

    下载后有两个文件:

    •model.onnx文件 (模型结构): 包含计算图、opset版本、节点配置等,文件较小。

    •model.data文件 (权重数据): 包含神经网络参数、权重等,文件较大。

    现在我们需要把这种分离文件格式的模型合并成一个文件,后续的操作都使用这个。

    合并文件:

    请使用JoyCode写个合并脚本即可, 提示词: 请写一个脚本, 把onnx模型文件的.onnx和.data文件合并。

    2.1.3 OM模型转换

    1. ONNX opset 版本转换

    当前使用CANN进行模型转换, 支持ONNX opset版本7\~18(最高支持到V1.13.1), 首先需要查看原始的onnx模型的opset版本是否在支持范围, 这里我们使用Netron(点击下载)可视化工具进行查看。
    在这里插入图片描述

    

    目前该模型使用的opset版本是20, 因此我们需要把该模型的opset版本转成18, 才可以用CANN转换成鸿蒙上可部署的模型。请使用JoyCode写个opset转换脚本即可, 提示词: 请写一个脚本, 把onnx模型文件的opset版本从20转换成18。

    

    2. OM离线模型****

    命令行中的参数说明请参见OMG参数,转换命令:

    ./tools/tools_omg/omg --model new_model_opset18.onnx --framework 5 --output ./model
    

    转换完成后, 生成model.om的模型文件, 该模型文件就是鸿蒙上可以正常使用的模型文件

    2.2 查看模型的输入/输出张量信息

    部署AI模式时, 我们需要确认模型的输入张量和输出张量信息, 请使用JoyCode编写一个脚本, 确定输入输出张量信息, 提示词: 写一个脚本查看onnx模型的输入输出张量信息。

    在这里插入图片描述

    2.2.1 输入张量

    BCHW格式, 是深度学习中常见的张量维度排列格式, 在图像处理场景中:

    •B (Batch): 批次大小 - 一次处理多少个样本。

    •C (Channel): 通道数 - 图像的颜色通道数。

    •H (Height): 高度 - 图像的像素高度。

    •W (Width): 宽度 - 图像的像素宽度。

    由此可以得出结论, 该模型1个批次处理1张宽高为128*128的RGB图片(因为C是3,因此不包含R通道)。

    

    2.2.2 输出张量

    该模型1个批次输出1张宽高为512*512的RGB图片。

    

    2.2.3 BCHW和BHWC格式的区别:

    超分模型中的BCHW和BHWC是两种不同的张量存储格式,主要区别在于通道维度的位置:

    

    BCHW格式(Batch-Channel-Height-Width)

    ◦维度顺序:[批次, 通道, 高度, 宽度]

    ◦内存布局:通道维度在空间维度之前

    ◦常用框架:PyTorch、TensorRT等

    示例: 形状为 (1, 3, 256, 256) 的RGB图像

    内存中的存储顺序: R通道的所有像素 -> G通道的所有像素 -> B通道的所有像素

    tensor_bchw = torch.randn(1, 3, 256, 256)
    访问第一个像素的RGB值需要跨越不同的内存区域
    pixel_0_0_r = tensor_bchw[0, 0, 0, 0]  # R通道
    pixel_0_0_g = tensor_bchw[0, 1, 0, 0]  # G通道  
    pixel_0_0_b = tensor_bchw[0, 2, 0, 0]  # B通道
    

    BHWC格式(Batch-Height-Width-Channel)

    ◦维度顺序:[批次, 高度, 宽度, 通道]

    ◦内存布局:通道维度在最后,像素的所有通道连续存储

    ◦常用框架:TensorFlow、OpenCV等

    示例:形状为 (1, 256, 256, 3) 的RGB图像

    内存中的存储顺序:像素(0,0)的RGB -> 像素(0,1)的RGB -> ... -> 像素(0,255)的RGB -> 像素(1,0)的RGB...

    tensor_bhwc = tf.random.normal([1, 256, 256, 3])
    # 访问第一个像素的RGB值在连续的内存位置
    pixel_0_0_rgb = tensor_bhwc[0, 0, 0, :]  # [R, G, B]
    

    

    3. 鸿蒙端部署核心步骤

    3.1 创建项目

    1.创建DevEco Studio项目,选择“Native C++”模板,点击“Next”。

    在这里插入图片描述

    

    2.按需填写“Project name”、“Save location”和“Module name”,选择“Compile SDK”为“5.1.0(18)”及以上版本,点击“Finish”。

    在这里插入图片描述

    3.2 配置项目NAPI

    CANN部署只提供了C++接口, 因此需要使用NAPI, 编译HAP时,NAPI层的so需要编译依赖NDK中的libneural\_network\_core.so和libhiai\_foundation.so。

    

    头文件引用

    按需引用NNCore和CANN Kit的头文件。

    #include "neural_network_runtime/neural_network_core.h"
    #include "CANNKit/hiai_options.h"
    

    编写CMakeLists.txt

    CMakeLists.txt示例代码如下。

    cmake_minimum_required(VERSION 3.5.0)
    project(myNpmLib)
    
    set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})
    
    include_directories(${NATIVERENDER_ROOT_PATH}
                        ${NATIVERENDER_ROOT_PATH}/include)
    
    include_directories(${HMOS_SDK_NATIVE}/sysroot/usr/lib)
    FIND_LIBRARY(cann-lib hiai_foundation)
    
    add_library(imagesr SHARED HIAIModelManager.cpp ImageSuperResolution.cpp)
    target_link_libraries(imagesr PUBLIC libace_napi.z.so
        libhilog_ndk.z.so
        librawfile.z.so
        ${cann-lib}
        libneural_network_core.so
        )
    

    3.3 集成模型

    模型的加载、编译和推理主要是在native层实现,应用层主要作为数据传递和展示作用。模型推理之前需要对输入数据进行预处理以匹配模型的输入,同样对于模型的输出也需要做处理获取自己期望的结果

    在这里插入图片描述

    3.3.1 加载离线模型

    为了让App运行时能够读取到模型文件和处理推理结果,需要先把离线模型和模型对应的结果标签文件预置到工程的“entry/src/main/resources/rawfile”目录中。

    在这里插入图片描述

    

    在App应用创建时加载模型:

    1.native层读取模型的buffer。

    const char* modelPath = "imagesr.om";
    RawFile *rawFile = OH_ResourceManager_OpenRawFile(resourceMgr, modelPath);
    long modelSize = OH_ResourceManager_GetRawFileSize(rawFile);
    std::unique_ptr<uint8_t[]> modelData = std::make_unique<uint8_t[]>(modelSize);
    int res = OH_ResourceManager_ReadRawFile(rawFile, modelData.get(), modelSize);
    

    2.使用模型的buffer, 调用OH\_NNCompilation\_ConstructWithOfflineModelBuffer创建模型的编译实例

    HiAI_Compatibility compibility = HMS_HiAICompatibility_CheckFromBuffer(modelData, modelSize);
    OH_NNCompilation *compilation = OH_NNCompilation_ConstructWithOfflineModelBuffer(modelData, modelSize);
    

    3.(可选)根据需要调用HMS\_HiAIOptions\_SetOmOptions接口,打开维测功能(如Profiling)。

    const char *out_path = "/data/storage/el2/base/haps/entry/files";
    HiAI_OmType omType = HIAI_OM_TYPE_PROFILING;
    OH_NN_ReturnCode ret = HMS_HiAIOptions_SetOmOptions(compilation, omType, out_path);     
    

    4.设置模型的deviceID。

    size_t deviceID = 0;
    const size_t *allDevicesID = nullptr;
    uint32_t deviceCount = 0;
    OH_NN_ReturnCode ret = OH_NNDevice_GetAllDevicesID(&allDevicesID, &deviceCount);
    
    for (uint32_t i = 0; i < deviceCount; i++) {
        const char *name = nullptr;
        ret = OH_NNDevice_GetName(allDevicesID[i], &name);
        if (ret != OH_NN_SUCCESS || name == nullptr) {
            OH_LOG_ERROR(LOG_APP, "OH_NNDevice_GetName failed");
            return deviceID;
        }
        if (std::string(name) == "HIAI_F") {
            deviceID = allDevicesID[i];
            break;
        }
    }
    
    ret = OH_NNCompilation_SetDevice(compilation, deviceID);
    

    5.调用OH\_NNCompilation\_Build,执行模型编译。

    ret = SetModelBuildOptions(compilation);
    ret = OH_NNCompilation_Build(compilation);
    

    6.调用OH\_NNExecutor\_Construct,创建模型执行器。

    executor_ = OH_NNExecutor_Construct(compilation);
    

    7.调用OH\_NNCompilation\_Destroy,释放模型编译实例。

    

    3.3.2 准备输入输出****Tensor

    1.处理模型的输入,模型的输入为13128*128格式(BCHW) Float类型的数据, 需要把RGB 数据转成BCHW格式并进行归一化。

    从图片中读取的RGB数据为BHWC,需要转换成模型可以识别的BCHW
    /**
     * 把bhwc转成bchw
     */
    uint8_t *rgbData = static_cast<uint8_t*>(data);
    uint8_t *floatData_tmp = new uint8_t[length];
    for (int c = 0; c < 3; ++c) {
        for (int h = 0; h < 128; ++h) {
            for (int w = 0; w < 128; ++w) {
                // HWC 索引: h * width * channels + w * channels +c 
                int hwc_index = h * 128 * 3 + w * 3 + c;
                // CHW 索引: C * height * width + h* width + W
                int chw_index = c * 128 * 128 + h * 128 + w;
                floatData_tmp[chw_index] = rgbData[hwc_index];
            }
        }
    }
    //归一化
    float *floatData = new float[length];
    for (size_t i = 0; i < length; ++i) {
        floatData[i] = static_cast<float>(floatData_tmp[i])/ 255.0f;
    }
    

    2.创建模型的输入和输出Tensor,并把应用层传递的数据填充到输入的Tensor中

    // 准备输入张量
    size_t inputCount = 0;
    OH_NN_ReturnCode ret = OH_NNExecutor_GetInputCount(executor_, &inputCount);
    for (size_t i = 0; i < inputCount; ++i) {
        NN_TensorDesc *tensorDesc = OH_NNExecutor_CreateInputTensorDesc(executor_, i);
        NN_Tensor *tensor = OH_NNTensor_Create(deviceID_, tensorDesc);
        if (tensor != nullptr) {
            inputTensors_.push_back(tensor);
        }
        OH_NNTensorDesc_Destroy(&tensorDesc);
    }
    
    
    ret = SetInputTensorData(inputTensors_, inputData);
    
    // 准备输出张量
    size_t outputCount = 0;
    ret = OH_NNExecutor_GetOutputCount(executor_, &outputCount);
    
    for (size_t i = 0; i < outputCount; i++) {
        NN_TensorDesc *tensorDesc = OH_NNExecutor_CreateOutputTensorDesc(executor_, i);
        NN_Tensor *tensor = OH_NNTensor_Create(deviceID_, tensorDesc);
        if (tensor != nullptr) {
            outputTensors_.push_back(tensor);
        }
        OH_NNTensorDesc_Destroy(&tensorDesc);
    }
    if (outputTensors_.size() != outputCount) {
        DestroyTensors(inputTensors_);
        DestroyTensors(outputTensors_);
        OH_LOG_ERROR(LOG_APP, "output size mismatch.");
        return OH_NN_FAILED;
    }
    

    

    3.3.3 进行推理

    调用OH\_NNExecutor\_RunSync,完成模型的同步推理。

    OH_NN_ReturnCode ret = OH_NNExecutor_RunSync(executor_, inputTensors_.data(), inputTensors_.size(),
                                                     outputTensors_.data(), outputTensors_.size());
    

    说明

    •如果不更换模型,则首次编译加载完成后可多次推理,即一次编译加载,多次推理。

    •所有关于模型的操作, 均无法多线程执行。

    

    3.3.4 获取模型输出并处理数据

    1.调用OH\_NNTensor\_GetDataBuffer,获取输出的Tensor,在输出Tensor中会得到模型的输出数据。

    // 获取第一个输出张量
    NN_Tensor* tensor = outputTensors_[0];
    
    // 获取张量数据缓冲区
    void *tensorData = OH_NNTensor_GetDataBuffer(tensor);
    
    // 获取张量大小
    size_t size = 0;
    OH_NN_ReturnCode ret = OH_NNTensor_GetSize(tensor, &size);
    
    float *tensorDataOutput = (float*)malloc(size);
    // 将tensorData的数据一次性复制到tensorDataOutput中
    memcpy(tensorDataOutput, tensorData, size);
    

    

    2.对Tensor输出数据进行相应的处理

    把模型输出的BCHW转成BHWC, 并进行反归一化处理

    

    //把模型输出的BCHW转成BHWC
    float *outputResult = static_cast<float *>(tensorData);
    float *output_tmp = new float[size/sizeof(float)];
    for (int h = 0; h < 512; ++h) {
        for (int w = 0; w < 512; ++w) {
            for (int c = 0; c < 3; ++c) {
                output_tmp[h * 512 * 3 + w* 3 + c] = outputResult[c * 512 * 512 + h * 512 + w];
            }
        }
    }
    std::vector<float> output(size / sizeof(float), 0.0);
    for (size_t i = 0; i < size / sizeof(float); ++i) {
        output[i] = output_tmp[i];
    }
    delete [] output_tmp;
    
    
     // 计算总的数据大小
    size_t totalSize = output.size();
    
    // 分配结果数据内存
    std::unique_ptr<uint8_t[]> result_data = std::make_unique<uint8_t[]>(totalSize);
    
    // 将float数据转换为uint8_t (反归一化)
    size_t index = 0;
    for (float value : result) {
        // 将float值转换为uint8_t (0-255范围)
        float scaledValue = value * 255.0f;
        scaledValue = std::max(0.0f, std::min(255.0f, scaledValue));
        result_data[index++] = static_cast<uint8_t>(scaledValue);
    }
    
    result_data 就是最终的超分数据,可以正常显示
    

    

    4. 总结与技术展望

    京东金融App在鸿蒙端部署Real-ESRGAN-General-x4v3超分辨率模型的完整实践过程,成功解决了ONNX模型到OM离线模型转换、BCHW与BHWC张量格式处理、以及基于CANN Kit和NAPI的完整部署链路等关键技术难题。

    展望端智能的未来发展,随着芯片算力的指数级增长、模型压缩技术的突破性进展以及边缘计算架构的日趋成熟,端侧设备将从单纯的数据采集终端演进为具备强大推理能力的智能计算节点,通过实现多模态AI融合、实时个性化学习、隐私保护计算和跨设备协同等核心能力,将大语言模型、计算机视觉、语音识别等AI技术深度集成到移动设备中,构建起无需联网即可提供智能服务的自主计算生态,推动人机交互从被动响应向主动感知、预测和服务的范式转变,最终开启真正意义上的普惠人工智能时代。

    在这里插入代码片作者:高阔

    1. 背景

    这可能是全网第一篇完整讲解鸿蒙端使用CANN部署AI模型的文章, 满满干货。

    社区作为用户交流、信息传递的核心载体,图片内容(如理财产品截图、投资经验分享配图、用户互动评论图片等)的展示质量直接影响用户的信息获取效率与平台信任感。从京东金融App社区的业务需求来看,当前用户上传图片普遍存在多样性失真问题:部分用户通过老旧设备拍摄的图片分辨率较低,部分用户为节省流量选择低画质压缩上传,还有部分截图类内容因原始来源清晰度不足导致信息模糊(如理财产品收益率数字、合同条款细节等),这些问题不仅降低了内容可读性,还可能因信息传递不清晰引发用户误解。

    京东金融App团队已完成Real-ESRGAN-General-x4v3超分辨率模型在安卓端的部署,能够针对性提升评论区、内容详情页、个人主页等核心场景的图片清晰度,从视觉体验层面优化用户留存与互动意愿。

    ESRGAN-General-x4v3模型在安卓端的部署,采用的是ONNX框架,该方案已有大量公开资料可参考,且取得显著业务成效。但鸿蒙端部署面临核心技术瓶颈:鸿蒙系统不支持ONNX框架,部署端侧AI仅能使用华为自研的CANN(Compute Architecture for Neural Networks)架构,且当前行业内缺乏基于CANN部署端侧AI的公开资料与成熟方案,全程需技术团队自主探索。接下来我会以ESRGAN-General-x4v3为例, 分享从模型转换(NPU亲和性改造)到端侧离线模型部署的全部过程。

    2. 部署前期准备

    2.1 离线模型转换

    CANN Kit当前仅支持Caffe、TensorFlow、ONNX和MindSpore模型转换为离线模型,其他格式的模型需要开发者自行转换为CANN Kit支持的模型格式。模型转换为OM离线模型,移动端AI程序直接读取离线模型进行推理。

    2.1.1 下载CANN工具

    从鸿蒙开发者官网下载 DDK-tools-5.1.1.1, 解压使用Tools下的OMG工具,将ONNX、TensorFlow模型转换为OM模型。(OMG工具位于Tools下载的tools/tools\_omg下,仅可运行在64位Linux平台上。)

    

    2.1.2 下载ESRGAN-General-x4v3模型文件

    https://aihub.qualcomm.com/compute/models/real\_esrgan\_general\_x4v3下载模型的onnx文件.

    注意: 下载链接中的a8a8的量化模型使用了高通的算子(亲测无法转换), CANN工具无法进行转换, 因此请下载float的量化模型。

    下载后有两个文件:

    •model.onnx文件 (模型结构): 包含计算图、opset版本、节点配置等,文件较小。

    •model.data文件 (权重数据): 包含神经网络参数、权重等,文件较大。

    现在我们需要把这种分离文件格式的模型合并成一个文件,后续的操作都使用这个。

    合并文件:

    请使用JoyCode写个合并脚本即可, 提示词: 请写一个脚本, 把onnx模型文件的.onnx和.data文件合并。

    2.1.3 OM模型转换

    1. ONNX opset 版本转换

    当前使用CANN进行模型转换, 支持ONNX opset版本7\~18(最高支持到V1.13.1), 首先需要查看原始的onnx模型的opset版本是否在支持范围, 这里我们使用Netron(点击下载)可视化工具进行查看。
    在这里插入图片描述

    

    目前该模型使用的opset版本是20, 因此我们需要把该模型的opset版本转成18, 才可以用CANN转换成鸿蒙上可部署的模型。请使用JoyCode写个opset转换脚本即可, 提示词: 请写一个脚本, 把onnx模型文件的opset版本从20转换成18。

    

    2. OM离线模型****

    命令行中的参数说明请参见OMG参数,转换命令:

    ./tools/tools_omg/omg --model new_model_opset18.onnx --framework 5 --output ./model
    

    转换完成后, 生成model.om的模型文件, 该模型文件就是鸿蒙上可以正常使用的模型文件

    2.2 查看模型的输入/输出张量信息

    部署AI模式时, 我们需要确认模型的输入张量和输出张量信息, 请使用JoyCode编写一个脚本, 确定输入输出张量信息, 提示词: 写一个脚本查看onnx模型的输入输出张量信息。

    在这里插入图片描述

    2.2.1 输入张量

    BCHW格式, 是深度学习中常见的张量维度排列格式, 在图像处理场景中:

    •B (Batch): 批次大小 - 一次处理多少个样本。

    •C (Channel): 通道数 - 图像的颜色通道数。

    •H (Height): 高度 - 图像的像素高度。

    •W (Width): 宽度 - 图像的像素宽度。

    由此可以得出结论, 该模型1个批次处理1张宽高为128*128的RGB图片(因为C是3,因此不包含R通道)。

    

    2.2.2 输出张量

    该模型1个批次输出1张宽高为512*512的RGB图片。

    

    2.2.3 BCHW和BHWC格式的区别:

    超分模型中的BCHW和BHWC是两种不同的张量存储格式,主要区别在于通道维度的位置:

    

    BCHW格式(Batch-Channel-Height-Width)

    ◦维度顺序:[批次, 通道, 高度, 宽度]

    ◦内存布局:通道维度在空间维度之前

    ◦常用框架:PyTorch、TensorRT等

    示例: 形状为 (1, 3, 256, 256) 的RGB图像

    内存中的存储顺序: R通道的所有像素 -> G通道的所有像素 -> B通道的所有像素

    tensor_bchw = torch.randn(1, 3, 256, 256)
    访问第一个像素的RGB值需要跨越不同的内存区域
    pixel_0_0_r = tensor_bchw[0, 0, 0, 0]  # R通道
    pixel_0_0_g = tensor_bchw[0, 1, 0, 0]  # G通道  
    pixel_0_0_b = tensor_bchw[0, 2, 0, 0]  # B通道
    

    BHWC格式(Batch-Height-Width-Channel)

    ◦维度顺序:[批次, 高度, 宽度, 通道]

    ◦内存布局:通道维度在最后,像素的所有通道连续存储

    ◦常用框架:TensorFlow、OpenCV等

    示例:形状为 (1, 256, 256, 3) 的RGB图像

    内存中的存储顺序:像素(0,0)的RGB -> 像素(0,1)的RGB -> ... -> 像素(0,255)的RGB -> 像素(1,0)的RGB...

    tensor_bhwc = tf.random.normal([1, 256, 256, 3])
    # 访问第一个像素的RGB值在连续的内存位置
    pixel_0_0_rgb = tensor_bhwc[0, 0, 0, :]  # [R, G, B]
    

    

    3. 鸿蒙端部署核心步骤

    3.1 创建项目

    1.创建DevEco Studio项目,选择“Native C++”模板,点击“Next”。

    在这里插入图片描述

    

    2.按需填写“Project name”、“Save location”和“Module name”,选择“Compile SDK”为“5.1.0(18)”及以上版本,点击“Finish”。

    在这里插入图片描述

    3.2 配置项目NAPI

    CANN部署只提供了C++接口, 因此需要使用NAPI, 编译HAP时,NAPI层的so需要编译依赖NDK中的libneural\_network\_core.so和libhiai\_foundation.so。

    

    头文件引用

    按需引用NNCore和CANN Kit的头文件。

    #include "neural_network_runtime/neural_network_core.h"
    #include "CANNKit/hiai_options.h"
    

    编写CMakeLists.txt

    CMakeLists.txt示例代码如下。

    cmake_minimum_required(VERSION 3.5.0)
    project(myNpmLib)
    
    set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})
    
    include_directories(${NATIVERENDER_ROOT_PATH}
                        ${NATIVERENDER_ROOT_PATH}/include)
    
    include_directories(${HMOS_SDK_NATIVE}/sysroot/usr/lib)
    FIND_LIBRARY(cann-lib hiai_foundation)
    
    add_library(imagesr SHARED HIAIModelManager.cpp ImageSuperResolution.cpp)
    target_link_libraries(imagesr PUBLIC libace_napi.z.so
        libhilog_ndk.z.so
        librawfile.z.so
        ${cann-lib}
        libneural_network_core.so
        )
    

    3.3 集成模型

    模型的加载、编译和推理主要是在native层实现,应用层主要作为数据传递和展示作用。模型推理之前需要对输入数据进行预处理以匹配模型的输入,同样对于模型的输出也需要做处理获取自己期望的结果

    在这里插入图片描述

    3.3.1 加载离线模型

    为了让App运行时能够读取到模型文件和处理推理结果,需要先把离线模型和模型对应的结果标签文件预置到工程的“entry/src/main/resources/rawfile”目录中。

    在这里插入图片描述

    

    在App应用创建时加载模型:

    1.native层读取模型的buffer。

    const char* modelPath = "imagesr.om";
    RawFile *rawFile = OH_ResourceManager_OpenRawFile(resourceMgr, modelPath);
    long modelSize = OH_ResourceManager_GetRawFileSize(rawFile);
    std::unique_ptr<uint8_t[]> modelData = std::make_unique<uint8_t[]>(modelSize);
    int res = OH_ResourceManager_ReadRawFile(rawFile, modelData.get(), modelSize);
    

    2.使用模型的buffer, 调用OH\_NNCompilation\_ConstructWithOfflineModelBuffer创建模型的编译实例

    HiAI_Compatibility compibility = HMS_HiAICompatibility_CheckFromBuffer(modelData, modelSize);
    OH_NNCompilation *compilation = OH_NNCompilation_ConstructWithOfflineModelBuffer(modelData, modelSize);
    

    3.(可选)根据需要调用HMS\_HiAIOptions\_SetOmOptions接口,打开维测功能(如Profiling)。

    const char *out_path = "/data/storage/el2/base/haps/entry/files";
    HiAI_OmType omType = HIAI_OM_TYPE_PROFILING;
    OH_NN_ReturnCode ret = HMS_HiAIOptions_SetOmOptions(compilation, omType, out_path);     
    

    4.设置模型的deviceID。

    size_t deviceID = 0;
    const size_t *allDevicesID = nullptr;
    uint32_t deviceCount = 0;
    OH_NN_ReturnCode ret = OH_NNDevice_GetAllDevicesID(&allDevicesID, &deviceCount);
    
    for (uint32_t i = 0; i < deviceCount; i++) {
        const char *name = nullptr;
        ret = OH_NNDevice_GetName(allDevicesID[i], &name);
        if (ret != OH_NN_SUCCESS || name == nullptr) {
            OH_LOG_ERROR(LOG_APP, "OH_NNDevice_GetName failed");
            return deviceID;
        }
        if (std::string(name) == "HIAI_F") {
            deviceID = allDevicesID[i];
            break;
        }
    }
    
    ret = OH_NNCompilation_SetDevice(compilation, deviceID);
    

    5.调用OH\_NNCompilation\_Build,执行模型编译。

    ret = SetModelBuildOptions(compilation);
    ret = OH_NNCompilation_Build(compilation);
    

    6.调用OH\_NNExecutor\_Construct,创建模型执行器。

    executor_ = OH_NNExecutor_Construct(compilation);
    

    7.调用OH\_NNCompilation\_Destroy,释放模型编译实例。

    

    3.3.2 准备输入输出****Tensor

    1.处理模型的输入,模型的输入为13128*128格式(BCHW) Float类型的数据, 需要把RGB 数据转成BCHW格式并进行归一化。

    从图片中读取的RGB数据为BHWC,需要转换成模型可以识别的BCHW
    /**
     * 把bhwc转成bchw
     */
    uint8_t *rgbData = static_cast<uint8_t*>(data);
    uint8_t *floatData_tmp = new uint8_t[length];
    for (int c = 0; c < 3; ++c) {
        for (int h = 0; h < 128; ++h) {
            for (int w = 0; w < 128; ++w) {
                // HWC 索引: h * width * channels + w * channels +c 
                int hwc_index = h * 128 * 3 + w * 3 + c;
                // CHW 索引: C * height * width + h* width + W
                int chw_index = c * 128 * 128 + h * 128 + w;
                floatData_tmp[chw_index] = rgbData[hwc_index];
            }
        }
    }
    //归一化
    float *floatData = new float[length];
    for (size_t i = 0; i < length; ++i) {
        floatData[i] = static_cast<float>(floatData_tmp[i])/ 255.0f;
    }
    

    2.创建模型的输入和输出Tensor,并把应用层传递的数据填充到输入的Tensor中

    // 准备输入张量
    size_t inputCount = 0;
    OH_NN_ReturnCode ret = OH_NNExecutor_GetInputCount(executor_, &inputCount);
    for (size_t i = 0; i < inputCount; ++i) {
        NN_TensorDesc *tensorDesc = OH_NNExecutor_CreateInputTensorDesc(executor_, i);
        NN_Tensor *tensor = OH_NNTensor_Create(deviceID_, tensorDesc);
        if (tensor != nullptr) {
            inputTensors_.push_back(tensor);
        }
        OH_NNTensorDesc_Destroy(&tensorDesc);
    }
    
    
    ret = SetInputTensorData(inputTensors_, inputData);
    
    // 准备输出张量
    size_t outputCount = 0;
    ret = OH_NNExecutor_GetOutputCount(executor_, &outputCount);
    
    for (size_t i = 0; i < outputCount; i++) {
        NN_TensorDesc *tensorDesc = OH_NNExecutor_CreateOutputTensorDesc(executor_, i);
        NN_Tensor *tensor = OH_NNTensor_Create(deviceID_, tensorDesc);
        if (tensor != nullptr) {
            outputTensors_.push_back(tensor);
        }
        OH_NNTensorDesc_Destroy(&tensorDesc);
    }
    if (outputTensors_.size() != outputCount) {
        DestroyTensors(inputTensors_);
        DestroyTensors(outputTensors_);
        OH_LOG_ERROR(LOG_APP, "output size mismatch.");
        return OH_NN_FAILED;
    }
    

    

    3.3.3 进行推理

    调用OH\_NNExecutor\_RunSync,完成模型的同步推理。

    OH_NN_ReturnCode ret = OH_NNExecutor_RunSync(executor_, inputTensors_.data(), inputTensors_.size(),
                                                     outputTensors_.data(), outputTensors_.size());
    

    说明

    •如果不更换模型,则首次编译加载完成后可多次推理,即一次编译加载,多次推理。

    •所有关于模型的操作, 均无法多线程执行。

    

    3.3.4 获取模型输出并处理数据

    1.调用OH\_NNTensor\_GetDataBuffer,获取输出的Tensor,在输出Tensor中会得到模型的输出数据。

    // 获取第一个输出张量
    NN_Tensor* tensor = outputTensors_[0];
    
    // 获取张量数据缓冲区
    void *tensorData = OH_NNTensor_GetDataBuffer(tensor);
    
    // 获取张量大小
    size_t size = 0;
    OH_NN_ReturnCode ret = OH_NNTensor_GetSize(tensor, &size);
    
    float *tensorDataOutput = (float*)malloc(size);
    // 将tensorData的数据一次性复制到tensorDataOutput中
    memcpy(tensorDataOutput, tensorData, size);
    

    

    2.对Tensor输出数据进行相应的处理

    把模型输出的BCHW转成BHWC, 并进行反归一化处理

    

    //把模型输出的BCHW转成BHWC
    float *outputResult = static_cast<float *>(tensorData);
    float *output_tmp = new float[size/sizeof(float)];
    for (int h = 0; h < 512; ++h) {
        for (int w = 0; w < 512; ++w) {
            for (int c = 0; c < 3; ++c) {
                output_tmp[h * 512 * 3 + w* 3 + c] = outputResult[c * 512 * 512 + h * 512 + w];
            }
        }
    }
    std::vector<float> output(size / sizeof(float), 0.0);
    for (size_t i = 0; i < size / sizeof(float); ++i) {
        output[i] = output_tmp[i];
    }
    delete [] output_tmp;
    
    
     // 计算总的数据大小
    size_t totalSize = output.size();
    
    // 分配结果数据内存
    std::unique_ptr<uint8_t[]> result_data = std::make_unique<uint8_t[]>(totalSize);
    
    // 将float数据转换为uint8_t (反归一化)
    size_t index = 0;
    for (float value : result) {
        // 将float值转换为uint8_t (0-255范围)
        float scaledValue = value * 255.0f;
        scaledValue = std::max(0.0f, std::min(255.0f, scaledValue));
        result_data[index++] = static_cast<uint8_t>(scaledValue);
    }
    
    result_data 就是最终的超分数据,可以正常显示
    

    

    4. 总结与技术展望

    京东金融App在鸿蒙端部署Real-ESRGAN-General-x4v3超分辨率模型的完整实践过程,成功解决了ONNX模型到OM离线模型转换、BCHW与BHWC张量格式处理、以及基于CANN Kit和NAPI的完整部署链路等关键技术难题。

    展望端智能的未来发展,随着芯片算力的指数级增长、模型压缩技术的突破性进展以及边缘计算架构的日趋成熟,端侧设备将从单纯的数据采集终端演进为具备强大推理能力的智能计算节点,通过实现多模态AI融合、实时个性化学习、隐私保护计算和跨设备协同等核心能力,将大语言模型、计算机视觉、语音识别等AI技术深度集成到移动设备中,构建起无需联网即可提供智能服务的自主计算生态,推动人机交互从被动响应向主动感知、预测和服务的范式转变,最终开启真正意义上的普惠人工智能时代。

    之前我每个月都会有一篇周记来总结当月的下载量和收入,也就是不管怎么流水账,每个月总有一篇是有内容的,内容就是下载量和收入的分析和总结。但是今年四月份的时候参加了一次独立开发者线下聚会活动,当时有人劝我不要再公开产品的下载量和收入,因为这确实带了一些小麻烦,从那以后我就没再写月度的数据总结,这回趁着年终总结,就写一个总体数字吧,以后每年就公布这一次。

    本文虽然写于 2026 年,但是会用“今年”来代指 2025 年,“去年”代指 2024 年。

    下载量(只统计极简时钟)

    • App Store ,363330 ,相较于去年提高 8.44%
    • Google Play ,182655 ,相较于去年下降 57.32%
    • 国内安卓市场,185007 ,相较于去年下降 33.77%

    App Store 终于止住了连续四年的下降,小幅提升了一下。Google Play 连续两年超过 50% 的下降,虽然总下载量突破了 400 万,但也快完犊子了。国内安卓市场也是连续两年下降,幅度只比 GP 小一点点。

    App 收入

    • App Store ,提高 40%
    • Google Play ,下降 45.71%
    • Admob ,下降 59%
    • 国内安卓市场,提高 45.63%

    App Store 的提高可能归功于年初时的一个改动,就是 App 在下载后第一次打开走完 Onboarding 流程后立刻展示 Paywall ,这个策略让收入短时间大幅上涨,甚至达到了 RevenueCat 的收费门槛,但是好景不长,收入趋势很快就下降到了之前的水平。

    Google Play 和 Admob 是绑在一起的,下载量的大幅下降也必然导致收入的大幅下降。虽热新 App 也接入了 Admob ,但是那部分收入完全可以忽略不计。

    国内安卓市场今年收入的上涨完全依赖于极简日记,极简日记在 3 月份被大 V 带了一下流量,收入猛增,从那以后就立刻改变了国内安卓市场的收入结构,原来大部分都是极简时钟,现在改成了极简日记。问题是极简日记的下载量太少了,和极简时钟差了一个数量级,如果下载量能提高一些就好了。

    2025 年的第一季度收入暴涨让我开心了半个多月,但是从那以后就一直下降,这一年也不怎么开心,我一直以为 2025 的总收入会不及 2024 年,可没想到年终一求和,居然比 2024 年还多,总体提高了 20%。扣除各种分成,到手的收入居然还突破 20 万,但也只突破了一丢丢,21 万左右。

    瞎折腾

    除了迭代已有的 App 外,今年还开发了几个新产品(但是都不赚钱),也拓展了新渠道(也不赚钱)。

    • 做了个 Web 应用,FlowFocus,就是用来保持心流的计时器,完全通过 AI 开发,没有任何流量。刚刚收到邮件通知,域名马上要过期了,续费要 30 多美元,算了。
    • 花了四个月写了个游戏 App ,Word Hack,没有内购,只有激励广告,第一次接入国内的广告平台,但是没有人玩,自然也没有收入。这个 App 从头到尾只有我一个人,所以也算一个独立游戏了,我也算是一个独立游戏开发者了。
    • 极简时钟成为 BYD Pad 的预装 App ,但是极简时钟的使用场景和车载 Pad 说实话不太搭,所以目前也没有任何的支付记录。不得不说,方程豹钛 7 这款车的销量真的很好。年底两个月有些天,来自比亚迪渠道的下载量居然不比国内其他安卓市场要差,有很多天甚至成为了日下载量第一的渠道。
    • 睡眠 App ,想要结合 HealthKit 和 MapKit 做一个睡眠习惯养成的 App ,写了一个多月的 demo 最后搁置了。为了开发这个 App 还特意买了 Apple Watch S10 ,参考很多了睡眠 App 的功能和设计,睡眠数据的读取和展示都搞通了,但是如何利用这些数据来做一些有趣的交互和没太想清楚。等到 WWDC25 之后,发现系统的睡眠 app 也有了很多新功能,我这个 App 感觉就没有继续开发的必要了。
    • 将极简时钟通过 Flutter 重写了一遍,尝试上架鸿蒙,失败了。

    广开财路

    除了来自于 APP 的直接收入,今年又多了几个收入来源,虽然不多,但是也是看到了其他赚钱的方法。
    这些收入来自于 Youtube 广告、知乎致知计划、公众号流量主,可以看出来,这些都属于内容创作。与 APP 的被动收入不同,这些收入必须持续投入,不断产出新内容才可以维持收入。

    YouTube 我今年只发了一个视频,不过这个视频也没有对收入有多少贡献,主要的贡献还是好多年前的一个视频。我是去年才开通了 YPP ,今年唯一一次打款是攒了一年才达到了打款门槛。知乎和公众号发的都是我的周记,知乎的收益只能靠浏览量产生的盐粒来兑换,今年也只兑换了一次。公众号这边的收入主要靠广告还有零星的打赏(今年的打赏比去年少太多的),周记写了将近三年,订阅数终于突破了一万,广告收入多的时候这个公众号的收益就相当于一个 20 万左右的货币基金。

    买买买

    查了一下今年给自己的消费记录,没有什么大的开销,超过一千块的只有下面几个,而且感觉都很物有所值,没花什么冤枉钱。

    • Apple Watch S10 ,趁着国补在京东官方旗舰店 1800+拿下的,应该是史上最低价了。
    • 电纸书,汉王 Clear 7 Turbo+,蹲了几个月终于在双十一拿下,为了颜值还多花了一点钱买的白色版。今年狂看悬疑推理小说,纸质书要买要等,看完了还出给多抓鱼,读电子版最方便,但是用手机和平板看太容易分心,老旧的 kindle 已不堪重用,所以十分需要一个开放系统的国产电纸书。自打买了以后几乎天天都在读,已经读完十本小说了。

    • 德龙全自动咖啡机,这是一笔冲动消费,当时受朋友影响,他买了个 2800 的耳机,我就觉得我也该买点什么提高一下生活质量。买完几天一直都是懊悔的状态,不是机器有问题,是我心里有问题,感觉这笔钱完全没必要。大半年用下来也还不错,可以不断买新豆子来尝试,也算是不断给生活带来新鲜感。

    • 荣耀平板,原来的小米平板 5 被孩子爷爷征用了,于是就买了个 13.3 寸的 MagicPad3 ,大屏就是爽,中午吃饭的时候我看电子榨菜,晚上老婆刷剧刷抖音,孩子上网课的时候也不费眼睛。
    • PS5 游戏,羊蹄山+死亡搁浅 2+刺客信条影,三个加起来也 1500 左右了,都白金了,值了。
    • 云鲸洗地机,换掉了科沃斯的扫拖一体机器人。之前每次清扫前先要给家里地上的东西都捡起来,而且有时候扫不干净第二次扫的时候电池就没电了,需要充两小时电才能继续清扫,还要时不时被地毯和桌腿困住,再加上加水和清理,清扫一次就要花半天时间,还给我折腾够呛。洗地机就不一样了,完全是人为操控,全屋拖一遍最多 15 分钟,方便太多了。
    • 今年疯狂想要买一辆小汽车,从油车看到新能源,特别是在免购置税政策的诱惑下,非常想拥有一辆自己的移动载具。但是因为车位、充电桩等问题,一直没有实际更进一步,就连唯一一次进入 4S 店,还是因为想要去看极简时钟在车载 Pad 上的样子才去的。后来动了买摩托车的念头,去驾校交完钱,结果体检没过,摩托车梦也碎了。趁着双十一的活动,还是买了人生第一辆新能源,一线大品牌,名字最后也是个“迪”,各项性能都十分满意,完美解决了交通问题。花费也是今年最大的一项,花了 3300 多。

    另外有一系列开销,可能加起来还不足一千,但却是我今年花费心力最多的,那就是养了仓鼠,一儿一女。养过仓鼠的人都知道,笼子里的东西七七八八,最便宜的就是仓鼠本身(除非是名贵的波利)。这一年里这两个小东西给我带来了很多快乐,白天自己一个人在家也不再孤单了。

    年终对账

    先来看一下去年年终总结里的 Flag:

    • 减重 15 斤!必须完成!
    • MRR 达到 600 美元。现在是 500 ,花一年时间提高 100 ,应该可以达到吧。
    • 出三个新 App ,一个健康类,一个效率类,一个学习类
    • 继续完善 Nut Goals ,并用这个 app 存下一笔钱
    • 尤克里里能够指弹一首歌

    来对一下吧:

    • 今年花了三个多月来实施 18+6 的轻断食(比 16+8 更严格),但是体重一直不降。今年上半年在家还有点运动,下半年就完全懒得动了。一年下来的断食还不如一次肠胃炎,一晚就掉了 5 斤……整体来看,没胖没瘦。
    • MRR 在三月份就达到了 600 美元,并且在之后最高达到 699 ,差点就够到了 700 ,可惜从那以后就一直下降了。
    • 健康类 App 就是上面的睡眠 App ,只停留在 demo 阶段。效率类 App 完全没有动手写。学习类 App ,就是上面提到的 Word Hack ,本来是想做个背单词的游戏化 App ,但是最终做成了游戏,也是三个里唯一一个正式发布的。
    • Nut Goals 修了个小 bug ,之后就再没更新。后来尝试用 Flutter 重写来上架安卓,但是只写了两个页面就搁置了。不过我最近产生了一个绝妙的功能,可以完美契合 App 的主题。
    • 尤克里里从下半年开始学习指弹,目前能够弹下来的就是简化版的菊花台,也算是完成目标了。

    看下来连 50% 都没有完成,这样也不好再立新目标了啊。这么一看,年度目标太遥远太笼统了,前半年拖一拖,后半年就会发现根本无法完成。有志者立长志,无志者常立志,于是每年都这样,立了也白立。年度目标应该拆解为月目标和周目标,再加上进度追踪、励志提醒和专家辅导。

    嘿,又一个 App 点子产生了!

    写在最后

    总的来说,今年很焦虑,大部分时间里是那种无能为力的感觉。

    今年上半年参加了独立开发者线下聚会,确实大开眼界。从那之后,我打算每周都找一个独立开发者进行线上聊天交流,但是只聊了五个人后就搁置了。希望 2026 年能够向更多的开发者请教,特别是岁数比我大的。

    唉,马上就四十岁了,希望可以不惑。