编程语言

TypeScript教程(八)产品实战 —— 移动应用(Capacitor + Ionic Vue / uni-app)

TypeScript 08:产品实战 —— 移动应用(Capacitor + Ionic Vue / uni-app)

用 TS + Vue 也能做手机 App,主要有三条路线:

方案 原理 产出 适合
PWA 网页 + Service Worker 可"添加到主屏幕"的网页 最轻量,不上应用商店(见 06 篇练习)
Capacitor + Ionic Vue WebView 中运行 Vue,通过插件调用相机、通知等原生能力 iOS / Android 安装包 已有 Vue Web 项目,想快速出 App
uni-app(DCloud) Vue 语法,编译到各平台 微信/支付宝小程序、H5、App 国内产品,小程序是刚需时
React Native / Expo JS 驱动原生控件 iOS / Android 对性能和原生体验要求高(需学 React)
Tauri 2 mobile 系统 WebView + Rust iOS / Android 已用 Tauri 做桌面版
(对照)Flutter / Kotlin / Swift 非 TS — 纯原生团队

本篇以 Capacitor + Ionic Vue 为主线,做一个手机记账 App:拍小票、离线记账联网自动同步、每晚提醒;
再用 uni-app 演示同一功能如何发布为微信小程序;最后用 WebdriverIO + Appium(TS) 做移动端自动化。

1. Capacitor + Ionic Vue

1.1 创建项目

npm install -g @ionic/cli
ionic start accounting-mobile tabs --type=vue --capacitor
cd accounting-mobile
ionic serve                       # 先在浏览器里开发调试,和普通 Vue 项目一样

npm install @capacitor/camera @capacitor/preferences @capacitor/network @capacitor/local-notifications
npx cap add android               # 需要 Android Studio
npx cap add ios                   # 需要 Xcode(仅 macOS)

日常流程:

ionic build && npx cap sync       # 把 Vue 构建产物和插件同步到原生工程
npx cap open android              # 用 Android Studio 打开,运行到模拟器/真机
ionic cap run android -l --external   # 真机热更新调试

调试 WebView:Android 用 Chrome 打开 chrome://inspect;iOS 用 Safari「开发」菜单。
和调试网页一样能看 DOM、Console、Network。

1.2 关键差异:手机上没有"同源后端"

浏览器里可以 fetch("/api/records"),但 App 里页面来自本地文件,必须写完整地址,后端需开启 CORS:

// src/api/client.ts
const BASE_URL = import.meta.env.VITE_API_BASE ?? "https://accounting.example.com";

export async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
  const token = await getToken();
  const res = await fetch(BASE_URL + path, {
    ...init,
    headers: {
      "Content-Type": "application/json",
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...init.headers,
    },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return (res.status === 204 ? null : await res.json()) as T;
}

1.3 本地存储(≈ 浏览器的 localStorage,但更可靠)

// src/services/storage.ts
import { Preferences } from "@capacitor/preferences";

export async function getToken(): Promise<string | null> {
  return (await Preferences.get({ key: "token" })).value;
}

export async function setToken(token: string): Promise<void> {
  await Preferences.set({ key: "token", value: token });
}

export async function getJson<T>(key: string, fallback: T): Promise<T> {
  const { value } = await Preferences.get({ key });
  return value ? (JSON.parse(value) as T) : fallback;
}

export async function setJson(key: string, data: unknown): Promise<void> {
  await Preferences.set({ key, value: JSON.stringify(data) });
}

1.4 离线优先:没网也能记账,联网自动同步

这是移动产品和网页最大的区别之一。

// src/services/syncQueue.ts
import { Network } from "@capacitor/network";
import { getJson, setJson } from "./storage";
import { request } from "../api/client";

export interface PendingRecord {
  localId: string;
  type: "income" | "expense"; category: string; amount: number; date: string;
  receiptPath?: string;
}

const KEY = "pending-records";

export async function addRecordOfflineFirst(r: Omit<PendingRecord, "localId">): Promise<void> {
  const queue = await getJson<PendingRecord[]>(KEY, []);
  queue.push({ ...r, localId: crypto.randomUUID() });
  await setJson(KEY, queue);
  await trySync();
}

let syncing = false;
export async function trySync(): Promise<number> {
  if (syncing || !(await Network.getStatus()).connected) return 0;
  syncing = true;
  let synced = 0;
  try {
    const queue = await getJson<PendingRecord[]>(KEY, []);
    const failed: PendingRecord[] = [];
    for (const item of queue) {
      try {
        const { localId, receiptPath, ...data } = item;
        await request("/api/records", { method: "POST", body: JSON.stringify(data) });
        synced++;
      } catch {
        failed.push(item);     // 失败的保留,下次再试
      }
    }
    await setJson(KEY, failed);
  } finally {
    syncing = false;
  }
  return synced;
}

// 在 main.ts 中调用一次:网络恢复时自动同步
export function startAutoSync(): void {
  Network.addListener("networkStatusChange", (status) => {
    if (status.connected) trySync();
  });
}

1.5 原生能力:拍小票、定时提醒

// src/services/native.ts
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
import { LocalNotifications } from "@capacitor/local-notifications";
import { Capacitor } from "@capacitor/core";

export async function takeReceiptPhoto(): Promise<string | undefined> {
  const photo = await Camera.getPhoto({
    quality: 70,
    resultType: CameraResultType.Uri,
    source: CameraSource.Prompt,     // 让用户选择 拍照 / 相册
  });
  return photo.webPath;              // 可直接作为 <img :src> 显示
}

export async function scheduleDailyReminder(): Promise<void> {
  if (!Capacitor.isNativePlatform()) return;   // 浏览器调试时跳过
  const { display } = await LocalNotifications.requestPermissions();
  if (display !== "granted") return;
  await LocalNotifications.schedule({
    notifications: [{
      id: 1,
      title: "记账提醒",
      body: "今天的账记了吗?",
      schedule: { on: { hour: 21, minute: 0 }, allowWhileIdle: true },
    }],
  });
}

使用相机需要声明权限:iOS 在 ios/App/App/Info.plist 添加 NSCameraUsageDescription、NSPhotoLibraryUsageDescription;
Android 一般由插件自动处理。权限被拒绝是移动端必测场景。

1.6 页面(Ionic 组件 = 移动端风格的 Element Plus)

<!-- src/views/AddRecordPage.vue -->
<script setup lang="ts">
import { reactive, ref } from "vue";
import {
  IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem,
  IonInput, IonSelect, IonSelectOption, IonButton, toastController,
} from "@ionic/vue";
import { takeReceiptPhoto } from "@/services/native";
import { addRecordOfflineFirst } from "@/services/syncQueue";

const form = reactive({
  type: "expense" as "income" | "expense",
  category: "",
  amount: 0,
  date: new Date().toISOString().slice(0, 10),
});
const receipt = ref<string>();

async function snap() {
  receipt.value = await takeReceiptPhoto();
}

async function save() {
  await addRecordOfflineFirst({ ...form, receiptPath: receipt.value });
  const toast = await toastController.create({ message: "已保存", duration: 1500 });
  await toast.present();
}
</script>

<template>
  <ion-page>
    <ion-header><ion-toolbar><ion-title>记一笔</ion-title></ion-toolbar></ion-header>
    <ion-content class="ion-padding">
      <ion-list>
        <ion-item>
          <ion-select v-model="form.type" label="类型">
            <ion-select-option value="expense">支出</ion-select-option>
            <ion-select-option value="income">收入</ion-select-option>
          </ion-select>
        </ion-item>
        <ion-item><ion-input v-model="form.category" label="分类" data-testid="category" /></ion-item>
        <ion-item><ion-input v-model.number="form.amount" type="number" label="金额" /></ion-item>
        <ion-item><ion-input v-model="form.date" type="date" label="日期" /></ion-item>
      </ion-list>
      <img v-if="receipt" :src="receipt" alt="小票" style="max-width: 100%" />
      <ion-button expand="block" fill="outline" @click="snap">拍小票</ion-button>
      <ion-button expand="block" @click="save">保存</ion-button>
    </ion-content>
  </ion-page>
</template>

1.7 发布

  • Android:Android Studio → Build → Generate Signed Bundle(.aab 上传 Google Play,国内各应用市场用 .apk)。
  • iOS:Xcode → Archive → 上传 App Store Connect(需苹果开发者账号)。
  • 热更新(不重新提交商店即可更新 Web 部分):Capgo / Appflow 等服务。

2. uni-app:一份 Vue 代码发布微信小程序

国内产品经常要求"App + 小程序 + H5"同时上线,uni-app 是主流选择。语法就是 Vue 3,但标签和 API 按小程序规范:

Web Vue uni-app
<div> / <span> <view> / <text>
<img> <image>
fetch / axios uni.request
localStorage uni.setStorageSync
vue-router pages.json 配置 + uni.navigateTo
npx degit dcloudio/uni-preset-vue#vite-ts accounting-uni
cd accounting-uni && npm install
npm run dev:h5          # 浏览器
npm run dev:mp-weixin   # 生成到 dist/dev/mp-weixin,用微信开发者工具打开
// src/api/request.ts —— 把 uni.request 封装成带类型的 Promise
export function request<T>(url: string, options: Partial<UniApp.RequestOptions> = {}): Promise<T> {
  return new Promise((resolve, reject) => {
    uni.request({
      url: "https://accounting.example.com" + url,
      header: { Authorization: `Bearer ${uni.getStorageSync("token")}` },
      ...options,
      success: (res) => (res.statusCode < 300 ? resolve(res.data as T) : reject(res)),
      fail: reject,
    });
  });
}
<!-- src/pages/index/index.vue -->
<script setup lang="ts">
import { ref } from "vue";
import { onShow, onPullDownRefresh } from "@dcloudio/uni-app";
import { request } from "@/api/request";

interface AccountRecord { id: number; type: string; category: string; amount: number; date: string }
const records = ref<AccountRecord[]>([]);

async function load() {
  records.value = await request<AccountRecord[]>(`/api/records?month=${new Date().toISOString().slice(0, 7)}`);
}
onShow(load);                                 // 小程序生命周期:页面每次显示
onPullDownRefresh(async () => { await load(); uni.stopPullDownRefresh(); });

const goAdd = () => uni.navigateTo({ url: "/pages/add/add" });
</script>

<template>
  <view class="list">
    <view v-for="r in records" :key="r.id" class="item">
      <text>{{ r.date }} {{ r.category }}</text>
      <text>{{ r.type === "income" ? "+" : "-" }}{{ r.amount }}</text>
    </view>
    <button @click="goAdd">记一笔</button>
  </view>
</template>

小程序上线前需在微信公众平台配置request 合法域名(必须 HTTPS),这也是常见的"测试环境正常、线上不通"原因。

3. 移动端自动化:WebdriverIO + Appium(TS 版)

你已有 Python + Appium 经验,TS 版本概念完全一致:

npm init wdio@latest ./mobile-e2e     # 选择 Appium、Mocha、TypeScript
npm install -g appium && appium driver install uiautomator2
// test/specs/record.e2e.ts
describe("记账 App", () => {
  it("离线新增记录后显示保存成功", async () => {
    // Ionic/Capacitor 是混合应用:界面在 WebView 中,先切换上下文
    await driver.waitUntil(async () => (await driver.getContexts()).length > 1);
    const contexts = (await driver.getContexts()) as string[];
    await driver.switchContext(contexts.find(c => c.startsWith("WEBVIEW"))!);

    await $('[data-testid="category"] input').setValue("food");
    await $("ion-button=保存").click();
    await expect($("ion-toast")).toBeDisplayed();

    // 切回原生上下文,处理系统权限弹窗等原生元素
    await driver.switchContext("NATIVE_APP");
  });
});
Python Appium WebdriverIO (TS)
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "x") $("~x")
driver.contexts / driver.switch_to.context() driver.getContexts() / driver.switchContext()
WebDriverWait waitForDisplayed() / waitUntil()
pytest fixture Mochabefore / beforeEach,wdio.conf.ts 的 hooks

移动端测试清单

  • 权限:首次拒绝 / 永久拒绝 / 设置中重新开启
  • 网络:飞行模式记账 → 恢复网络 → 自动同步,不重复、不丢失
  • 生命周期:切后台再回来、来电打断、杀进程后数据是否保留
  • 适配:刘海屏、小屏、深色模式、系统字体放大
  • 升级:旧版本数据升级到新版本后是否正常

4. 练习

  1. 用 Ionic Vue 完成"手机记账":列表页 + 新增页 + 拍小票 + 离线同步,后端用 06 篇的 Nuxt 项目(改为 Bearer Token 认证)。
  2. 用 uni-app 把列表页发布到微信开发者工具中运行。
  3. 用 WebdriverIO 写一个混合应用用例,覆盖"无网络新增 → 恢复网络 → 服务端出现该记录"。

文章评论 (0)