Skip to content

移动端插件适配

Obsidian 在 iOS 和 Android 上都有原生应用,但移动端与桌面端存在显著差异。本文介绍如何为你的插件添加移动端支持。

桌面端与移动端差异

核心差异

特性桌面端移动端
Node.js API✅ 完整❌ 不可用
Electron API✅ 可用❌ 不可用
文件系统访问✅ 完整⚠️ 受限
子进程child_process❌ 不可用
本地服务器✅ 可创建❌ 不可用
剪贴板 APInavigator.clipboard⚠️ 受限
屏幕尺寸
输入方式键盘鼠标触屏

API 可用性对比

typescript
// ❌ 移动端不可用
const { exec } = require("child_process");
const fs = require("fs");
const path = require("path");
const electron = require("electron");

// ✅ 移动端可用
import { FileSystemAdapter, Vault } from "obsidian";
import { Platform } from "obsidian";

检测运行平台

使用 Platform API

typescript
import { Platform } from "obsidian";

// 检测是否为移动端
if (Platform.isMobile) {
  // 移动端特定逻辑
}

// 检测具体平台
if (Platform.isIos) {
  // iOS 特定逻辑
}

if (Platform.isAndroid) {
  // Android 特定逻辑
}

if (Platform.isDesktop) {
  // 桌面端特定逻辑
}

if (Platform.isMacOS) {
  // macOS 特定逻辑
}

在 manifest.json 中声明

json
{
  "id": "my-plugin",
  "name": "My Plugin",
  "version": "1.0.0",
  "minAppVersion": "1.0.0",
  "isDesktopOnly": false
}

重要

如果 isDesktopOnly 设为 true,移动端用户将无法安装你的插件。只有在使用了 Electron 或 Node.js 特有 API 且无法降级时才设为 true

常见适配场景

文件系统操作

桌面端可以直接使用 Node.js 的 fs 模块,移动端需要使用 Obsidian API:

typescript
import { Platform, FileSystemAdapter, TFile, TFolder } from "obsidian";

class MyPlugin extends Plugin {
  async readExternalFile(path: string): Promise<string> {
    if (Platform.isDesktop) {
      // 桌面端:使用 Node.js API
      const { readFileSync } = require("fs");
      return readFileSync(path, "utf-8");
    } else {
      // 移动端:仅能访问仓库内文件
      const file = this.app.vault.getAbstractFileByPath(path);
      if (file instanceof TFile) {
        return await this.app.vault.read(file);
      }
      throw new Error(`File not found: ${path}`);
    }
  }

  async writeExternalFile(path: string, content: string): Promise<void> {
    if (Platform.isDesktop) {
      const { writeFileSync } = require("fs");
      writeFileSync(path, content, "utf-8");
    } else {
      // 移动端:只能写入仓库内文件
      const file = this.app.vault.getAbstractFileByPath(path);
      if (file instanceof TFile) {
        await this.app.vault.modify(file, content);
      } else {
        await this.app.vault.create(path, content);
      }
    }
  }
}

子进程与命令执行

移动端无法执行系统命令,需要提供替代方案:

typescript
import { Platform } from "obsidian";

class MyPlugin extends Plugin {
  async executeCommand(command: string): Promise<string> {
    if (Platform.isMobile) {
      // 移动端:使用内置逻辑替代
      return this.executeCommandFallback(command);
    }

    // 桌面端:使用 child_process
    const { exec } = require("child_process");
    return new Promise((resolve, reject) => {
      exec(command, (error: Error | null, stdout: string) => {
        if (error) reject(error);
        else resolve(stdout);
      });
    });
  }

  private async executeCommandFallback(command: string): Promise<string> {
    // 使用 Obsidian API 实现替代逻辑
    new Notice("此功能在移动端不可用,使用替代方案");
    return "";
  }
}

网络请求

移动端的网络请求需要使用 requestUrl API:

typescript
import { requestUrl, RequestUrlParam } from "obsidian";

async function fetchData(url: string): Promise<any> {
  // ✅ 跨平台网络请求
  const response = await requestUrl({
    url: url,
    method: "GET",
    headers: {
      "Content-Type": "application/json",
    },
  });
  return response.json;
}

// ❌ 避免使用 fetch(部分移动端环境可能不支持)
async function fetchDataNotRecommended(url: string) {
  const response = await fetch(url); // 可能在移动端失败
  return response.json();
}

剪贴板操作

typescript
async function copyToClipboard(text: string): Promise<void> {
  if (Platform.isMobile) {
    // 移动端:使用 Obsidian API
    await navigator.clipboard.writeText(text);
  } else {
    // 桌面端:可以使用 Electron clipboard
    const { clipboard } = require("electron");
    clipboard.writeText(text);
  }
}

UI 适配

响应式布局

移动端屏幕小,需要调整布局:

typescript
class MyView extends ItemView {
  async onOpen() {
    const container = this.containerEl.children[1] as HTMLElement;

    if (Platform.isMobile) {
      // 移动端:紧凑布局
      container.classList.add("mobile-layout");
    } else {
      // 桌面端:宽松布局
      container.classList.add("desktop-layout");
    }
  }
}

CSS 适配

css
/* 基础样式 */
.my-plugin-container {
  padding: 16px;
}

/* 移动端适配 */
.is-mobile .my-plugin-container {
  padding: 8px;
  font-size: 14px;
}

.is-mobile .my-plugin-sidebar {
  width: 100%;
}

.is-mobile .my-plugin-two-column {
  flex-direction: column;
}

/* iOS 安全区域 */
.is-ios .my-plugin-container {
  padding-bottom: env(safe-area-inset-bottom);
}

触摸操作

typescript
// 为移动端添加触摸事件
class TouchHandler {
  private element: HTMLElement;
  private longPressTimer: number | null = null;

  constructor(element: HTMLElement) {
    this.element = element;
    this.setupTouchHandlers();
  }

  private setupTouchHandlers() {
    if (Platform.isMobile) {
      // 长按触发上下文菜单
      this.element.addEventListener("touchstart", (e) => {
        this.longPressTimer = window.setTimeout(() => {
          this.showContextMenu(e);
        }, 500);
      });

      this.element.addEventListener("touchend", () => {
        if (this.longPressTimer) {
          clearTimeout(this.longPressTimer);
        }
      });

      this.element.addEventListener("touchmove", () => {
        if (this.longPressTimer) {
          clearTimeout(this.longPressTimer);
        }
      });
    } else {
      // 桌面端:右键菜单
      this.element.addEventListener("contextmenu", (e) => {
        this.showContextMenu(e);
      });
    }
  }
}

性能优化

减少内存使用

移动端设备内存有限,需要特别注意:

typescript
// ❌ 不好的做法:缓存大量数据
const allNotesCache: Map<string, string> = new Map();

// ✅ 好的做法:按需加载
class LazyLoader {
  private cache = new Map<string, string>();
  private maxCacheSize = Platform.isMobile ? 50 : 200;

  async getNoteContent(path: string): Promise<string> {
    if (this.cache.has(path)) {
      return this.cache.get(path)!;
    }

    const file = this.app.vault.getAbstractFileByPath(path);
    if (!(file instanceof TFile)) throw new Error("File not found");

    const content = await this.app.vault.read(file);

    // LRU 缓存策略
    if (this.cache.size >= this.maxCacheSize) {
      const firstKey = this.cache.keys().next().value;
      if (firstKey) this.cache.delete(firstKey);
    }

    this.cache.set(path, content);
    return content;
  }
}

节流与防抖

typescript
function debounce<T extends (...args: any[]) => any>(
  func: T,
  wait: number
): (...args: Parameters<T>) => void {
  let timeout: number | null = null;
  return function (...args: Parameters<T>) {
    if (timeout) clearTimeout(timeout);
    timeout = window.setTimeout(() => func(...args), wait);
  };
}

// 移动端使用更长的防抖时间
const DEBOUNCE_TIME = Platform.isMobile ? 500 : 200;
const debouncedSearch = debounce(performSearch, DEBOUNCE_TIME);

懒加载

typescript
class MyView extends ItemView {
  private isLoading = false;

  async onOpen() {
    // 只加载可见区域的数据
    await this.loadVisibleData();
    this.setupScrollObserver();
  }

  private setupScrollObserver() {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            this.loadItem(entry.target.dataset.path!);
          }
        });
      },
      { rootMargin: Platform.isMobile ? "200px" : "400px" }
    );

    this.containerEl.querySelectorAll(".lazy-item").forEach((el) => {
      observer.observe(el);
    });
  }
}

功能降级策略

渐进增强

typescript
class FeatureManager {
  get availableFeatures(): string[] {
    const features = ["basic-search", "tags"];

    if (Platform.isDesktop) {
      features.push("local-server", "external-scripts", "git-integration");
    }

    if ("Notification" in window) {
      features.push("notifications");
    }

    if ("share" in navigator)) {
      features.push("web-share");
    }

    return features;
  }

  isFeatureAvailable(feature: string): boolean {
    return this.availableFeatures.includes(feature);
  }
}

优雅降级

typescript
class ShareManager {
  async shareContent(title: string, text: string, url?: string): Promise<void> {
    // 优先使用 Web Share API(移动端友好)
    if (navigator.share) {
      try {
        await navigator.share({ title, text, url });
        return;
      } catch (e) {
        // 用户取消分享
        if (e instanceof Error && e.name === "AbortError") return;
      }
    }

    // 降级:复制到剪贴板
    await navigator.clipboard.writeText(text);
    new Notice("内容已复制到剪贴板");
  }
}

测试移动端适配

桌面端模拟

  1. 打开开发者工具 (Ctrl/Cmd + Shift + I)
  2. 切换设备模拟模式
  3. 选择手机型号

Obsidian 移动端调试

iOS 调试步骤:

  1. 在 Mac 上打开 Safari → 开发 → 你的 iOS 设备
  2. 选择 Obsidian WebView
  3. 在控制台中调试

Android 调试步骤:

  1. 在 Chrome 中打开 chrome://inspect
  2. 找到 Obsidian WebView
  3. 点击 inspect

兼容性测试清单

markdown
## 移动端测试清单

### 安装与加载
- [ ] 插件在移动端正常加载
- [ ] 无控制台错误
- [ ] 设置页面正常显示

### 功能测试
- [ ] 核心功能正常工作
- [ ] 降级功能正常工作
- [ ] 网络请求正常
- [ ] 文件操作正常

### UI 测试
- [ ] 界面不超出屏幕
- [ ] 按钮大小适合触摸
- [ ] 文字大小可读
- [ ] 滚动流畅

### 性能测试
- [ ] 启动时间 < 2 秒
- [ ] 无明显卡顿
- [ ] 内存占用合理
- [ ] 电池消耗正常

### 兼容性
- [ ] iOS 测试通过
- [ ] Android 测试通过
- [ ] iPad 测试通过
- [ ] 不同屏幕尺寸适配

常见问题

插件在移动端加载失败?

检查是否使用了桌面端专有 API:

  1. 搜索 require("electron")require("fs")
  2. 确保所有 Node.js API 调用有移动端替代
  3. 检查 manifest.jsonisDesktopOnly 设置

移动端通知不工作?

typescript
// 使用 Obsidian 的 Notice 替代系统通知
import { Notice } from "obsidian";

new Notice("操作成功");

移动端键盘遮挡内容?

css
/* iOS 键盘适配 */
.is-ios .input-container {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 100;
  transition: transform 0.3s ease;
}

相关资源