AnkiDroid扩展插件开发指南:定制专属学习功能的技术实践

【免费下载链接】Anki-Android AnkiDroid: Anki flashcards on Android. Your secret trick to achieve superhuman information retention. 【免费下载链接】Anki-Android 项目地址: https://gitcode.com/gh_mirrors/an/Anki-Android

1. 插件开发痛点与解决方案

你是否在使用AnkiDroid时遇到以下问题:标准功能无法满足特定学习场景需求?想要添加语音识别、自定义评分系统却无从下手?本文将通过技术实践,带你从零构建一个AnkiDroid扩展插件,掌握JS API调用、生命周期管理和打包发布全流程。

读完本文你将获得:

  • 完整的插件项目结构设计方案
  • 10+核心API调用代码示例
  • 3种主流插件类型的实现模板
  • 自动化测试与调试技巧
  • 发布到插件市场的最佳实践

2. AnkiDroid插件架构解析

2.1 系统架构概览

AnkiDroid采用分层架构设计,插件系统基于JavaScript接口(JS API)与原生Android代码交互:

mermaid

2.2 插件类型与应用场景

AnkiDroid支持两种官方插件类型,适用场景如下表:

插件类型 入口点 典型应用场景 生命周期 权限范围
复习器插件(reviewer) 卡片复习界面 自定义评分、语音输入、内容增强 随卡片加载/销毁 单卡片操作
笔记编辑器插件(note-editor) 添加/编辑笔记界面 内容模板、富文本编辑、媒体插入 随编辑器生命周期 笔记全字段访问

3. 开发环境搭建

3.1 基础环境配置

# 克隆项目仓库
git clone https://gitcode.com/gh_mirrors/an/Anki-Android
cd Anki-Android

# 构建调试版本
./gradlew assembleDebug

# 安装到连接设备
./gradlew installDebug

3.2 插件项目结构

推荐采用模块化结构组织插件代码:

my-ankidroid-plugin/
├── src/
│   ├── js/                # JavaScript核心代码
│   │   ├── reviewer.js    # 复习器逻辑
│   │   └── editor.js      # 编辑器逻辑
│   ├── assets/            # 静态资源
│   │   ├── icons/         # 图标资源
│   │   └── styles/        # CSS样式表
│   └── tests/             # 单元测试
├── package.json           # 插件元数据
├── rollup.config.js       # 打包配置
└── README.md              # 使用文档

4. 核心API详解与实战

4.1 初始化与版本控制

所有插件必须在package.json中声明API版本和类型,确保兼容性:

{
  "name": "vocab-builder-plugin",
  "version": "1.0.0",
  "addonType": "reviewer",
  "ankidroidJsApi": "6.0.0",
  "keywords": ["ankidroid-js-addon", "vocabulary", "language-learning"],
  "main": "dist/reviewer.js",
  "icon": "📚",
  "addonTitle": "词汇自动生成器",
  "author": {
    "name": "Your Name",
    "email": "dev@example.com"
  },
  "homepage": "https://example.com/ankidroid-plugin"
}

版本验证逻辑在原生代码中的实现:

private fun requireApiVersion(apiVer: String, apiDevContact: String): Boolean {
    try {
        val versionCurrent = Version.parse(AnkiDroidJsAPIConstants.CURRENT_JS_API_VERSION)
        val versionSupplied = Version.parse(apiVer)
        
        return when {
            versionSupplied == versionCurrent -> true
            versionSupplied.isLowerThan(versionCurrent) -> {
                showUpdateMessage(apiDevContact)
                versionSupplied.isHigherThanOrEquivalentTo(MINIMUM_JS_API_VERSION)
            }
            else -> {
                showInvalidVersionMessage(apiDevContact)
                false
            }
        }
    } catch (e: Exception) {
        Timber.w(e, "API version validation failed")
        return false
    }
}

4.2 复习器插件核心API

4.2.1 卡片操作API

以下是卡片标记功能的完整实现,包含错误处理和用户反馈:

// 标记当前卡片
function markCurrentCard() {
    const apiContract = {
        version: "6.0.0",
        developer: "dev@example.com",
        data: ""
    };
    
    anki.call("markCard", JSON.stringify(apiContract))
        .then(result => {
            if (result.success) {
                showToast("卡片已标记", true);
                updateMarkIndicator(true);
            } else {
                showToast("标记失败: " + result.value, false);
            }
        })
        .catch(error => {
            console.error("标记操作异常:", error);
            showToast("系统错误,请重试", false);
        });
}

// 设置卡片标记状态UI
function updateMarkIndicator(isMarked) {
    const indicator = document.getElementById("mark-indicator");
    indicator.style.display = "block";
    indicator.textContent = isMarked ? "✓ 已标记" : "○ 未标记";
    indicator.className = isMarked ? "marked" : "unmarked";
}
4.2.2 语音合成(TTS)API

实现带语速调节的文本朗读功能:

// 初始化TTS引擎
let ttsEngine = {
    language: "en-US",
    pitch: 1.0,
    rate: 1.0
};

// 设置TTS参数
function configureTTS() {
    anki.call("ttsSetLanguage", JSON.stringify(apiContract), ttsEngine.language)
        .then(result => {
            if (result.success) {
                anki.call("ttsSetPitch", JSON.stringify(apiContract), ttsEngine.pitch);
                anki.call("ttsSetSpeechRate", JSON.stringify(apiContract), ttsEngine.rate);
            } else {
                showToast("TTS初始化失败", false);
            }
        });
}

// 朗读卡片内容
function speakCardContent() {
    if (anki.call("ttsIsSpeaking", JSON.stringify(apiContract)).value) {
        anki.call("ttsStop", JSON.stringify(apiContract));
        return;
    }
    
    const cardData = {
        text: document.getElementById("front-content").textContent,
        queueMode: 1 // 1=替换当前队列,0=追加到队列
    };
    
    anki.call("ttsSpeak", JSON.stringify(apiContract), JSON.stringify(cardData));
}
4.2.3 卡片评分API

自定义评分逻辑实现:

// 自定义评分处理
function handleCustomRating(rating) {
    // 1-简单 2-中等 3-困难 4-非常困难
    const ratingMap = {1: "AGAIN", 2: "HARD", 3: "GOOD", 4: "EASY"};
    
    if (!ratingMap[rating]) {
        showToast("无效评分值", false);
        return;
    }
    
    // 调用原生评分API
    anki.call(`answerEase${rating}`, JSON.stringify(apiContract))
        .then(result => {
            if (result.success) {
                // 记录自定义学习数据
                logLearningAnalytics(rating, Date.now());
            }
        });
}

4.3 笔记编辑器插件开发

4.3.1 字段操作API

实现多字段内容同步更新:

// 同步更新相关字段
function syncRelatedFields(sourceFieldId) {
    const sourceValue = document.getElementById(sourceFieldId).value;
    
    if (sourceFieldId === "word-field") {
        // 自动生成音标
        generatePhoneticTranscription(sourceValue)
            .then(phonetic => {
                document.getElementById("phonetic-field").value = phonetic;
            });
            
        // 自动翻译
        translateText(sourceValue, "en", "zh-CN")
            .then(translation => {
                document.getElementById("translation-field").value = translation;
            });
    }
}

// 监听字段变化
document.querySelectorAll(".note-field").forEach(field => {
    field.addEventListener("change", (e) => {
        syncRelatedFields(e.target.id);
    });
});
4.3.2 媒体资源管理

实现图片自动上传与嵌入:

// 处理拖放图片
function handleImageDrop(event) {
    event.preventDefault();
    const files = event.dataTransfer.files;
    
    if (files.length > 0 && files[0].type.startsWith("image/")) {
        const reader = new FileReader();
        
        reader.onload = function(e) {
            // 调用原生API上传图片
            const base64Data = e.target.result.split(",")[1];
            const mediaData = {
                filename: `plugin-image-${Date.now()}.png`,
                data: base64Data,
                field: "image-field"
            };
            
            anki.call("addMedia", JSON.stringify(apiContract), JSON.stringify(mediaData))
                .then(result => {
                    if (result.success) {
                        // 在编辑器中插入图片
                        const imgTag = `![image](${result.value})`;
                        document.getElementById("image-field").value += imgTag;
                    }
                });
        };
        
        reader.readAsDataURL(files[0]);
    }
}

5. 高级功能实现

5.1 语音识别集成

实现基于STT的语音输入功能:

// 语音识别模块
const speechRecognizer = {
    isListening: false,
    
    start: function(fieldId) {
        if (this.isListening) return;
        
        this.isListening = true;
        document.getElementById("stt-status").textContent = "正在聆听...";
        
        // 设置识别语言
        anki.call("sttSetLanguage", JSON.stringify(apiContract), "zh-CN");
        
        // 开始识别
        anki.call("sttStart", JSON.stringify(apiContract))
            .then(result => {
                if (!result.success) {
                    this.stop();
                    showToast("语音识别初始化失败", false);
                }
            });
    },
    
    stop: function() {
        if (!this.isListening) return;
        
        this.isListening = false;
        document.getElementById("stt-status").textContent = "语音输入";
        anki.call("sttStop", JSON.stringify(apiContract));
    }
};

// 原生回调处理
function ankiSttResult(result) {
    const parsedResult = JSON.parse(result);
    if (parsedResult.success) {
        const field = document.getElementById("target-field");
        field.value += parsedResult.value[0]; // 取最佳识别结果
    } else {
        showToast("识别失败: " + parsedResult.value, false);
    }
    speechRecognizer.stop();
}

5.2 自定义设置界面

创建带存储功能的插件设置面板:

// 设置面板HTML
const settingsPanel = `
<div class="settings-panel">
    <h3>插件设置</h3>
    <div class="setting-item">
        <label>默认朗读语言:</label>
        <select id="default-language">
            <option value="en-US">英语(美国)</option>
            <option value="zh-CN">中文(中国)</option>
            <option value="ja-JP">日语(日本)</option>
        </select>
    </div>
    <div class="setting-item">
        <label>自动播放语音:</label>
        <input type="checkbox" id="auto-play-tts">
    </div>
    <button onclick="saveSettings()">保存设置</button>
</div>
`;

// 加载并应用保存的设置
function loadSettings() {
    // 调用原生API获取设置
    anki.call("getPluginSettings", JSON.stringify(apiContract))
        .then(result => {
            if (result.success && result.value) {
                const settings = JSON.parse(result.value);
                document.getElementById("default-language").value = settings.language || "en-US";
                document.getElementById("auto-play-tts").checked = settings.autoPlay || false;
            }
        });
}

// 保存设置
function saveSettings() {
    const settings = {
        language: document.getElementById("default-language").value,
        autoPlay: document.getElementById("auto-play-tts").checked,
        lastUpdated: Date.now()
    };
    
    // 调用原生API保存设置
    anki.call("setPluginSettings", JSON.stringify(apiContract), JSON.stringify(settings))
        .then(result => {
            if (result.success) {
                showToast("设置已保存", true);
                // 应用新设置
                ttsEngine.language = settings.language;
                configureTTS();
            }
        });
}

6. 项目实战:单词自动生成插件

6.1 功能需求与设计

开发一个能根据输入单词自动生成释义、例句和发音的插件,包含以下功能:

  • 单词自动查询与信息提取
  • 多字段内容自动填充
  • 自定义例句生成
  • 发音文件自动下载

6.2 完整项目结构

word-auto-generator/
├── src/
│   ├── js/
│   │   ├── editor.js        # 编辑器主逻辑
│   │   ├── api-client.js    # 词典API客户端
│   │   └── template-engine.js # 内容模板引擎
│   ├── assets/
│   │   ├── icons/
│   │   │   ├── logo.svg
│   │   │   └── refresh.svg
│   │   └── styles/
│   │       └── editor.css   # 自定义样式
│   └── tests/
│       ├── api-client.test.js
│       └── template-engine.test.js
├── package.json
├── rollup.config.js         # 打包配置
└── README.md

6.3 核心实现代码

词典API客户端:
// api-client.js
const DictionaryApi = {
    baseUrl: "https://api.dictionaryapi.dev/api/v2/entries",
    
    async fetchWordData(word, language = "en") {
        try {
            const response = await fetch(`${this.baseUrl}/${language}/${encodeURIComponent(word)}`);
            
            if (!response.ok) {
                throw new Error(`API请求失败: ${response.status}`);
            }
            
            return this.processApiResponse(await response.json());
        } catch (error) {
            console.error("词典API错误:", error);
            showToast("单词查询失败,请检查网络连接", false);
            return null;
        }
    },
    
    processApiResponse(rawData) {
        // 提取所需数据
        const firstEntry = rawData[0];
        if (!firstEntry) return null;
        
        return {
            word: firstEntry.word,
            phonetic: firstEntry.phonetic || "",
            meanings: firstEntry.meanings.map(meaning => ({
                partOfSpeech: meaning.partOfSpeech,
                definitions: meaning.definitions.map(def => def.definition),
                examples: meaning.definitions
                    .filter(def => def.example)
                    .map(def => def.example)
            })),
            audio: firstEntry.phonetics.find(p => p.audio)?.audio || ""
        };
    }
};
主逻辑实现:
// editor.js
async function generateWordCard() {
    const wordInput = document.getElementById("word-input").value.trim();
    if (!wordInput) {
        showToast("请输入单词", false);
        return;
    }
    
    // 显示加载状态
    setLoadingState(true);
    
    try {
        // 查询单词数据
        const wordData = await DictionaryApi.fetchWordData(wordInput);
        if (!wordData) return;
        
        // 填充字段
        fillNoteFields(wordData);
        
        // 下载音频(如果有)
        if (wordData.audio) {
            downloadAudio(wordData.word, wordData.audio);
        }
        
        showToast("单词卡片生成成功", true);
    } finally {
        // 隐藏加载状态
        setLoadingState(false);
    }
}

function fillNoteFields(wordData) {
    // 填充单词字段
    anki.call("setFieldValue", JSON.stringify(apiContract), {
        field: "Word",
        value: wordData.word
    });
    
    // 填充音标字段
    anki.call("setFieldValue", JSON.stringify(apiContract), {
        field: "Phonetic",
        value: wordData.phonetic ? `[${wordData.phonetic}]` : ""
    });
    
    // 填充释义字段
    let definitions = "";
    wordData.meanings.forEach(meaning => {
        definitions += `*${meaning.partOfSpeech}*\n`;
        meaning.definitions.forEach((def, index) => {
            definitions += `${index + 1}. ${def}\n`;
        });
        definitions += "\n";
    });
    
    anki.call("setFieldValue", JSON.stringify(apiContract), {
        field: "Definition",
        value: definitions.trim()
    });
    
    // 填充例句字段
    const examples = wordData.meanings
        .flatMap(meaning => meaning.examples)
        .slice(0, 3); // 最多3个例句
    
    if (examples.length > 0) {
        anki.call("setFieldValue", JSON.stringify(apiContract), {
            field: "Example",
            value: examples.map((ex, i) => `${i + 1}. ${ex}`).join("\n")
        });
    }
}

7. 测试与调试

7.1 单元测试框架

使用Jest构建插件单元测试:

// api-client.test.js
describe("DictionaryApi", () => {
    beforeEach(() => {
        global.fetch = jest.fn();
    });
    
    afterEach(() => {
        jest.resetAllMocks();
    });
    
    test("fetchWordData返回正确结构", async () => {
        // 模拟API响应
        fetch.mockResolvedValueOnce({
            ok: true,
            json: async () => [mockApiResponse]
        });
        
        const result = await DictionaryApi.fetchWordData("test");
        
        expect(result).toHaveProperty("word", "test");
        expect(result).toHaveProperty("meanings");
        expect(Array.isArray(result.meanings)).toBeTruthy();
        expect(result.meanings.length).toBeGreaterThan(0);
    });
    
    test("处理API错误", async () => {
        // 模拟网络错误
        fetch.mockRejectedValueOnce(new Error("网络错误"));
        
        const result = await DictionaryApi.fetchWordData("test");
        expect(result).toBeNull();
    });
});

7.2 调试技巧

AnkiDroid插件调试可以通过以下方式实现:

  1. 日志输出
// 分级日志
function logDebug(message) {
    if (process.env.DEBUG) {
        anki.call("logDebug", JSON.stringify(apiContract), message);
    }
}

function logError(message) {
    anki.call("logError", JSON.stringify(apiContract), message);
}
  1. 远程调试
# 启用Android WebView远程调试
adb shell am set-debug-app -w com.ichi2.anki
adb forward tcp:9222 localabstract:webview_devtools_remote_<进程ID>
  1. 错误监控
// 全局错误处理
window.addEventListener("error", (event) => {
    logError(`JavaScript错误: ${event.message} at ${event.filename}:${event.lineno}`);
    showToast("插件发生错误,请查看日志", false);
});

// Promise错误处理
window.addEventListener("unhandledrejection", (event) => {
    logError(`未处理的Promise错误: ${event.reason}`);
    showToast("操作失败,请重试", false);
});

8. 打包与发布流程

8.1 构建脚本

使用Rollup.js创建打包配置:

// rollup.config.js
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { terser } from 'rollup-plugin-terser';
import copy from 'rollup-plugin-copy';

export default {
    input: 'src/js/editor.js',
    output: {
        file: 'dist/reviewer.js',
        format: 'iife',
        name: 'AnkiPlugin',
        sourcemap: process.env.NODE_ENV !== 'production'
    },
    plugins: [
        nodeResolve(),
        commonjs(),
        process.env.NODE_ENV === 'production' && terser(),
        copy({
            targets: [
                { src: 'src/assets/**/*', dest: 'dist/assets' },
                { src: 'package.json', dest: 'dist' }
            ]
        })
    ]
};

8.2 打包命令

package.json中添加构建脚本:

{
  "scripts": {
    "build": "rollup -c",
    "watch": "rollup -c -w",
    "build:prod": "NODE_ENV=production rollup -c",
    "test": "jest",
    "lint": "eslint src/**/*.js",
    "prepublish": "npm run build:prod && npm test"
  }
}

执行打包:

# 开发构建(带sourcemap)
npm run build

# 生产构建(压缩代码)
npm run build:prod

8.3 发布到NPM

  1. 确保package.json符合AnkiDroid插件规范
  2. 登录NPM账号:npm login
  3. 发布插件:npm publish --access public

插件审核会验证以下内容:

  • 必须包含ankidroid-js-addon关键词
  • addonType必须是reviewernote-editor
  • ankidroidJsApi版本必须与当前支持版本匹配
  • 必须提供有效的homepage地址

9. 插件开发最佳实践

9.1 性能优化

  1. 延迟加载
// 延迟加载非关键组件
function lazyLoadComponents() {
    // 当用户滚动到特定位置或触发特定事件时加载
    const observer = new IntersectionObserver((entries) => {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                loadAdvancedFeatures();
                observer.disconnect();
            }
        });
    });
    
    observer.observe(document.getElementById("lazy-load-trigger"));
}
  1. 事件委托
// 使用事件委托减少事件监听器
document.getElementById("field-container").addEventListener("click", (event) => {
    if (event.target.matches(".audio-play-btn")) {
        const audioId = event.target.dataset.audioId;
        playAudio(audioId);
    } else if (event.target.matches(".example-btn")) {
        insertExample(event.target.dataset.exampleText);
    }
});
  1. 批量操作
// 批量更新字段,减少API调用
function batchUpdateFields(fieldUpdates) {
    return anki.call("batchUpdateFields", JSON.stringify(apiContract), JSON.stringify(fieldUpdates));
}

// 使用方式
batchUpdateFields([
    { field: "Front", value: "新内容" },
    { field: "Back", value: "新背面" },
    { field: "Tags", value: "updated" }
]);

9.2 可访问性设计

  1. 键盘导航
// 添加键盘快捷键
document.addEventListener("keydown", (event) => {
    // Alt+G生成卡片
    if (event.altKey && event.key === "g") {
        event.preventDefault();
        generateWordCard();
    }
    // Alt+L朗读内容
    else if (event.altKey && event.key === "l") {
        event.preventDefault();
        speakCardContent();
    }
});
  1. 屏幕阅读器支持
<!-- 添加ARIA属性 -->
<button aria-label="生成单词卡片" 
        aria-expanded="false" 
        aria-controls="advanced-options">
    生成卡片
</button>

<!-- 状态更新通知 -->
<div role="status" class="sr-only" id="screen-reader-notice"></div>
// 更新屏幕阅读器通知
function updateScreenReaderNotice(message) {
    document.getElementById("screen-reader-notice").textContent = message;
}

9.3 兼容性处理

// 特性检测与降级处理
function initializePlugin() {
    // 检查API支持情况
    checkApiSupport();
    
    // 适配不同屏幕尺寸
    adaptToScreenSize();
    
    // 检测深色模式
    if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
        applyDarkTheme();
    }
}

// 渐进式功能增强
function checkApiSupport() {
    const supportedApis = [
        "ttsSpeak", "ttsStop", "setFieldValue", 
        "getFieldValue", "addTagToNote"
    ];
    
    // 检查关键API是否可用
    supportedApis.forEach(api => {
        if (!anki.hasApi(api)) {
            console.warn(`API ${api}不受支持,相关功能将被禁用`);
            disableFeatureForMissingApi(api);
        }
    });
}

10. 总结与进阶方向

通过本文的技术实践,你已经掌握了AnkiDroid插件开发的核心技能。未来可以探索以下进阶方向:

  1. AI集成:结合GPT等API实现智能内容生成
  2. 离线数据同步:使用Service Worker实现离线功能
  3. 跨平台兼容:适配不同尺寸的Android设备和AnkiDroid版本
  4. 高级媒体处理:实现图片标注、视频剪辑等富媒体功能

想要获取更多资源:

  • 官方API文档:查看AnkiDroid源码中的AnkiDroidJsAPI.kt
  • 插件示例库:https://github.com/ankidroid/Anki-Android/tree/main/examples
  • 社区支持:AnkiDroid官方论坛插件开发板块

希望本文能帮助你构建出强大的学习辅助工具,提升记忆效率。如果你开发了优秀的插件,欢迎在评论区分享你的作品和经验!

点赞+收藏+关注,不错过更多AnkiDroid高级开发技巧,下期将带来《插件性能优化与内存管理实战》。

【免费下载链接】Anki-Android AnkiDroid: Anki flashcards on Android. Your secret trick to achieve superhuman information retention. 【免费下载链接】Anki-Android 项目地址: https://gitcode.com/gh_mirrors/an/Anki-Android

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐