从 0 到 1:用 CJMP 开发「每日早报」鸿蒙应用完整实战

本文记录使用 CJMP(仓颉跨平台框架)+ 仓颉语言 + HarmonyOS,从空白工程到实现一个调用 ALAPI「每日早报」接口的鸿蒙应用,并在真机上跑通的完整过程,包含所有编译报错与启动闪退的排查思路。

一、目标与背景

我们要做一个小应用:打开后自动请求 ALAPI 的每日早报接口,把当天的 15 条新闻简报和微语展示在屏幕上,支持手动刷新。

API 信息:

  • 接口:POST https://v3.alapi.cn/api/zaobao
  • 参数:token(必填)、format=json
  • 返回:codedata.datedata.news(字符串数组)、data.weiyu

技术栈:

  • CJMP SDK v0.2.2(跨平台 UI 引擎 Keels)
  • 仓颉语言(HarmonyOS Cangjie 1.1.0)
  • ArkUI 声明式组件 + @State 状态管理
  • ohos.net.http(HarmonyOS 网络 HTTP Kit)

二、环境准备(简略版)

macOS(Apple Silicon)+ DevEco Studio 6.1 + OpenHarmony SDK 26.0.0 + CJMP SDK v0.2.2 + 鸿蒙真机(Mate 60 Pro)。

要点回顾:

  1. git clone --depth 1 -b open-sdk-mac-v0.2.2 https://atomgit.com/CJMP/OpenSDK.git ~/cjmp-sdk
  2. ~/.zshrc 配置 CJMP_SDK_HOME 与 PATH
  3. 从 DevEco Studio 的 CJMP 插件包中提取 build-toolsapi(命令行构建 ohos 需要)
  4. xattr -dr com.apple.quarantine 解除 Gatekeeper 拦截
  5. 构建时设置 DEVECO_CANGJIE_PATH / DEVECO_CANGJIE_PLUGIN_ENABLED

详细过程可参考上一篇文章《从零搭建 CJMP 开发环境并运行到鸿蒙设备》。

三、创建项目

keels create ~/Desktop/cjpm/demo --name demo
cd ~/Desktop/cjpm/demo

生成的三端壳工程中,我们只关心 lib/(仓颉跨平台逻辑)和 hos/(鸿蒙壳):

demo/
├── project.conf        # Keels 项目配置
├── lib/                # 仓颉源码(三端共享)
│   ├── main_ability.cj # UIAbility 入口,loadContent("EntryView")
│   ├── index.cj        # ⭐ 主界面组件 EntryView
│   ├── ability_mainability_entry.cj
│   └── cjpm.toml
└── hos/                # HarmonyOS 壳工程
    └── entry/src/main/module.json5   # ⭐ 模块配置(权限)

main_ability.cj 负责把名为 EntryView 的组件加载到窗口:

class MainAbility <: UIAbility {
    public override func onWindowStageCreate(windowStage: WindowStage): Unit {
        windowStage.loadContent("EntryView")
    }
}

我们要写的全部 UI 和网络逻辑都在 lib/index.cjEntryView 里。

四、界面设计:ArkUI 声明式写法

仓颉中的 ArkUI 组件用法与 ArkTS 几乎一致:@Entry @Component 标注组件类,@State 标注响应式变量,build() 里用声明式语法搭界面。

@Entry
@Component
class EntryView {
    @State
    var newsContent: String = "正在获取今日早报..."
    @State
    var isLoading: Bool = true

    func build() {
        Row {
            Column {
                Text("📰 每日早报")
                    .fontSize(28)
                    .fontWeight(FontWeight.Bold)
                    .margin(20)

                if (isLoading) {
                    LoadingProgress().width(40).height(40)
                }

                Scroll {
                    Text(newsContent)
                        .fontSize(16)
                        .lineHeight(28)
                        .padding(16)
                }.layoutWeight(1).scrollBar(BarState.Auto)

                Button("刷新")
                    .width(200).height(44).margin(20)
                    .onClick({ evt => loadNews() })
            }.width(100.percent)
        }.height(100.percent)
    }

    public override func aboutToAppear(): Unit {
        loadNews()   // 页面首次出现即拉取数据
    }
}

仓颉语法小贴士:

  • 属性链式调用与 ArkTS 相同:.fontSize(28).fontWeight(...)
  • 点击回调是无参风格:.onClick({ evt => loadNews() })
  • 尺寸用数字 + 隐式单位;百分比用 100.percent
  • margin/padding 只传单个数值即可四边生效,不支持 { top: 20 } 这种对象字面量
  • 生命周期回调是 aboutToAppear(),不是 onAppear()(后者会报"没有可覆写的方法")

五、网络请求:Cangjie 调用 HarmonyOS HTTP Kit

5.1 先看 API 声明文件

CJMP SDK 里每个预编译模块都带一份 .cj.d 声明文件(相当于头文件),写代码前先读它:

~/cjmp-sdk/cjmp-tools/api/modules/linux_ohos_aarch64_cjnative/ohos/ohos.net.http.cj.d

从中确认关键 API:

  • 顶层函数 public func createHttp(): HttpRequest(注意:不是类方法
  • HttpRequest.request(url, options, callback),需要 ohos.permission.INTERNET
  • 回调类型 AsyncCallback<HttpResponse>,即 (Option<BusinessException>, Option<HttpResponse>) -> Unit
  • HttpRequestOptions 里有 methodheadertimeoutexpectDataType
  • HttpResponse.result 是枚举 HttpDataStringData(String) / ArrayData(Array<Byte>)
  • HTTP 方法枚举成员是 RequestMethod.Post(首字母大写驼峰)

5.2 踩坑一:必须"逐符号导入"

这是编译期最大的坑。三种导入方式:

// ❌ 模块导入:符号全部解析不到(undeclared)
import ohos.net.http

// ❌ 通配符导入:同样解析不到(undeclared)
import ohos.net.http.*

// ✅ 逐个符号显式导入:正常可用
import ohos.net.http.createHttp
import ohos.net.http.HttpRequest
import ohos.net.http.HttpRequestOptions
import ohos.net.http.HttpResponse
import ohos.net.http.RequestMethod
import ohos.net.http.HttpData
import ohos.net.http.HttpDataType

排查技巧:写一个只引用某个符号的最小文件单独编译,可以快速确认符号是否可用。实测用「单符号导入」即可编译通过。

5.3 请求代码

func loadNews(): Unit {
    isLoading = true
    newsContent = "正在获取今日早报..."

    let httpRequest = createHttp()
    let options = HttpRequestOptions()
    options.method = RequestMethod.Post
    options.connectTimeout = 10000
    options.readTimeout = 10000
    options.header = HashMap<String, String>()
    options.header["Content-Type"] = "application/x-www-form-urlencoded"
    options.expectDataType = HttpDataType.StringValue   // 强制字符串返回

    let url = "https://v3.alapi.cn/api/zaobao?token=${API_TOKEN}&format=json"

    try {
        httpRequest.request(url, options, {
            err: Option<BusinessException>, data: Option<HttpResponse> =>
                // ... 回调处理,见 5.4
        })
    } catch (e: BusinessException) {
        newsContent = "请求异常: ${e.message}"
        isLoading = false
    }
}

注意 request() 声明为 throwexception: true,同步调用也要包 try/catch

5.4 回调里的状态更新必须回主线程

回调参数是 Option 包裹的,先 match 解包。但直接在这里给 @State 变量赋值会闪退,原因见第七节。最终写法是把所有状态更新包进 ohos.base.launch({...})

httpRequest.request(url, options, {
    err: Option<BusinessException>, data: Option<HttpResponse> =>
        // HTTP 回调在后台线程,必须切回主线程再更新 @State
        launch({
            =>
                match (err) {
                    case Some(e) =>
                        newsContent = "请求失败: ${e.message}"
                        isLoading = false
                    case None =>
                        match (data) {
                            case Some(resp) =>
                                if (resp.responseCode == 200) {
                                    let text = extractText(resp.result)
                                    match (text) {
                                        case Some(s) => newsContent = extractNews(s)
                                        case None => newsContent = "返回数据格式不是字符串"
                                    }
                                } else {
                                    newsContent = "请求失败,状态码: ${resp.responseCode}"
                                }
                            case None => newsContent = "请求失败:无返回数据"
                        }
                        isLoading = false
                }
        })
})

关键导入: import ohos.base.launch(同样是逐符号导入)。launch 的官方注释是 “Submit the task to the main thread for execution”——它就是 ArkUI 状态管理的主线程调度入口。

六、启动闪退排查实录

第一版代码编译通过、安装成功,但一点击应用图标就闪退。排查工具与思路:

6.1 抓崩溃日志

hdc shell "hilog -x" | grep -iE 'cjerror|BusinessException|Uncaught exception|Faultlog'

看到系统写入了 Cangjie 错误日志:

SaveFaultLogToFile: create log cjerror-com.example.demo-20021078-xxx.log
Reason:ohos.business_exception:BusinessException

/data/log/faultlog/faultlogger/ 目录 shell 直接 cat 会 Permission denied,但可以用 hdc file recv 拉到本地:

hdc file recv /data/log/faultlog/faultlogger/cjerror-com.example.demo-xxx.log /tmp/cjerror.log

6.2 根因一:缺少 INTERNET 权限

崩溃日志显示 BusinessException,而 HttpRequest.request() 的声明里明确写着:

permission: "ohos.permission.INTERNET", throwexception: true

即:没申请网络权限就发起请求 → 抛权限异常(错误码 201)→ 未捕获 → 闪退。

修复:在 hos/entry/src/main/module.json5 的 module 里加权限声明:

"requestPermissions": [
  { "name": "ohos.permission.INTERNET" }
],

6.3 根因二:@State 不能在后台线程更新

加上权限后重新安装,仍然闪退。再抓一次日志,堆栈直接指到问题代码:

Exception info: State updates are not performed on the main thread.
  at ohos.arkui.state_management.ObservedProperty<...>::set(...)
  at EntryView::newsContent::set(.../lib/index.cj:25)
  at EntryView::loadNews::lambda.0()(.../lib/index.cj:99)   <-- HTTP 回调

原因:HTTP 异步回调运行在后台线程(FFI 回调线程),而 ArkUI 规定 @State 属性只能在 UI 主线程修改,否则抛异常闪退。

修复:把回调里所有状态更新包进 ohos.base.launch({...}) 切回主线程(见 5.4 代码)。

七、数据解析优化:两处"非预期"

修完闪退,界面能稳定显示了,但先后出现两个数据问题。

7.1 “返回数据格式不是字符串”

现象:界面显示兜底文案「返回数据格式不是字符串」。

排查:HttpResponse.resultHttpData 枚举,此前只处理了 StringData。实际响应命中 ArrayData(字节数组)。

修复分两层:

  1. 源头:请求时设置 options.expectDataType = HttpDataType.StringValue,告诉框架返回字符串。
  2. 兜底:无论哪种形态都能解析——新增 extractText()
func extractText(result: HttpData): Option<String> {
    match (result) {
        case HttpData.StringData(text) => Some(text)
        case HttpData.ArrayData(bytes) => Some(String.fromUtf8(bytes)) // Byte == UInt8 别名
        case _ => None   // 枚举有第三个成员,必须补全分支否则编译报 non-exhaustive
    }
}

注意:Cangjie 里 Byte 就是 UInt8 的别名(public type Byte = UInt8),所以字节数组可以直接传给 String.fromUtf8,无需手动转换。

7.2 “未获取到新闻内容”

现象:界面有日期、有微语,但新闻列表是空的。

排查:先在 Mac 上用 curl 实测接口真实返回:

curl -s -X POST "https://v3.alapi.cn/api/zaobao?token=xxx&format=json"

发现关键差异——返回的 news 是字符串数组,不是对象数组

"data": {
  "date": "2026-09-04",
  "news": ["1、青海海西州发生5.1级地震…", "2、世界气象组织…"],
  "weiyu": "【微语】…"
}

而第一版解析器按 [{"title":…,"content":…}] 的对象结构去匹配 "title":",自然永远匹配不到。

修复:按真实结构解析——定位 "news":[" 后逐条截取到下一个 ",跳过 "," 分隔符继续;最后再单独提取 weiyu 字段拼到末尾。同时给每条加序号前缀方便阅读。

let newsKey = "\"news\":[\""
let keyPos = safeIndexOf(jsonStr, newsKey, 0)
if (keyPos >= 0) {
    var textFrom = keyPos + newsKey.size   // 直接指向首条文本
    var index = 0
    while (itemCount < 40) {
        let closeQ = safeIndexOf(jsonStr, "\"", textFrom)
        if (closeQ <= textFrom) { break }
        let itemText = jsonStr[textFrom .. closeQ]
        if (itemText.size == 0) { break }
        index += 1
        content = content + "${index}. ${itemText}\n\n"
        itemCount += 1
        // 条间分隔 ",":跳过 ',' 与下一项的开引号
        let nextOpenQ = safeIndexOf(jsonStr, "\"", closeQ + 1)
        if (nextOpenQ < 0) { break }
        textFrom = nextOpenQ + 1
    }
}

经验教训:客户端解析前先 curl 看真实返回,别凭文档/直觉猜结构;字符串数组 ≠ 对象数组。

八、构建、安装与真机验证

8.1 构建 HAP

export CJMP_SDK_HOME=$HOME/cjmp-sdk
export PATH=$CJMP_SDK_HOME/cjmp-tools/bin:$CJMP_SDK_HOME/cjmp-tools/build-tools/tools/bin:$HOME/Library/OpenHarmony/Sdk/26.0.0/toolchains:$PATH
export DEVECO_CANGJIE_PATH=$HOME/cjmp-sdk/cjmp-tools
export DEVECO_CANGJIE_PLUGIN_ENABLED=true

cd ~/Desktop/cjpm/demo
rm -rf hos/.hvigor   # 清缓存,避免增量误判
keels build hap -v   # → BUILD SUCCESSFUL

产物在:hos/entry/build/default/outputs/default/entry-default-signed.hap

8.2 安装与启动

keels run 只支持 Android(走 adb),鸿蒙设备要用 hdc 手动装:

export PATH=$HOME/Library/OpenHarmony/Sdk/26.0.0/toolchains:$PATH

hdc uninstall com.example.demo                      # 每次先卸干净,权限配置才生效
hdc install hos/entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b com.example.demo

验证进程存活、无新崩溃:

hdc shell "pidof com.example.demo"                  # 有输出 = 存活
hdc shell "hilog -x" | grep -E 'cjerror|Uncaught exception'   # 无输出 = 没崩

8.3 用 UI 树验证数据真的显示了

肉眼确认之外,还能用 uitest 导出界面控件树,程序化检查 Text 内容:

hdc shell "uitest dumpLayout -p /data/local/tmp/layout.json"
hdc file recv /data/local/tmp/layout.json /tmp/layout.json

解析 JSON 后就能看到界面上的真实文本——最终验证结果:

📰 每日早报
📅 2026-09-04
1. 青海海西州发生5.1级地震,中国地震局启动四级应急响应…
2. 世界气象组织确认厄尔尼诺已形成…
…(共 15 条)
💬 【微语】愿你在平凡的日子里,也活出自己的光…

image-20260904191136226

九、总结与避坑清单

从零到真机运行,最大的成本不在写功能,而在理解 Cangjie 与 HarmonyOS Kit 的对接约定。核心避坑清单:

#一句话解法
1import ohos.net.http 后符号 undeclared逐符号导入import ohos.net.http.HttpRequest
2回调参数报错/类型对不上回调是 AsyncCallback(Option<BusinessException>, Option<HttpResponse>)
3请求抛异常闪退request() 声明 throwexception,需 try/catch
4BusinessException 闪退先查 module.json5 是否声明 INTERNET 权限
5“State updates are not performed on the main thread”网络回调里更新 @State 要包 ohos.base.launch({...})
6枚举成员写错RequestMethod.Post(驼峰),不是 POST
7响应是字节数组options.expectDataType = HttpDataType.StringValue,兜底 String.fromUtf8
8match 报 non-exhaustiveHttpData 有第三个成员,补 case _ => None
9解析结果为空先 curl 真实返回——news 是字符串数组不是对象数组
10keels run 不支持鸿蒙hdc install + hdc shell aa start

方法论沉淀:

  1. 先读 .cj.d 声明文件再写代码——每个 API 的签名、权限、异常都写在注释里,比猜 API 高效得多。
  2. 崩溃先抓日志——hilog -x + hdc file recv 拉 cjerror 日志,堆栈会精确指到出错的那一行源码。
  3. 解析前先 curl——接口真实返回永远比文档更可信。
  4. 状态更新必须回主线程——这是 ArkUI 状态管理的铁律,网络层回调默认在后台线程。

希望这篇实战记录能帮你少走弯路。完整的「每日早报」应用虽然简单,但它把 Cangjie + ArkUI + HTTP + 权限 + 线程 + JSON 解析这些鸿蒙原生开发的要点串了起来,是一份很好的起步样例。


相关链接:

  • CJMP 组织:https://atomgit.com/CJMP
  • CJMP SDK:https://atomgit.com/CJMP/OpenSDK
  • CJMP 文档:https://atomgit.com/CJMP/Docs
  • ALAPI 每日早报接口文档:https://v3.alapi.cn/api/zaobao
  • 项目地址:https://atomgit.com/jianguoxu/cjmp-demo
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐