Skip to content

Xime 插件开发完整指南

插件系统架构

Xime 采用动态加载插件架构,表情插件只提供数据,由主应用负责展示。

┌─────────────────────────────────────────────┐
│          主应用 (Xime  APK)                   │
│                                              │
│  ┌─────────────────────────────────────┐   │
│  │   PluginManager                      │   │
│  │   - PluginClassLoader 加载插件APK    │   │
│  │   - PluginLifecycleManager 管理生命周期 │   │
│  └─────────────────────────────────────┘   │
│                                              │
│  ┌─────────────────────────────────────┐   │
│  │   ExtensionManager                   │   │
│  │   - 管理表情数据                     │   │
│  │   - 提供 emojiCategoriesFlow        │   │
│  └─────────────────────────────────────┘   │
└─────────────────────────────────────────────┘

            │ PluginClassLoader 加载

┌─────────────────────────────────────────────┐
│       插件 APK (独立安装)                    │
│                                              │
│  ┌─────────────────────────────────────┐   │
│  │   EmojiPlugin 实现                   │   │
│  │   - onLoad(PluginContext)            │   │
│  │   - onUnload()                       │   │
│  │   - getEmojis() 提供表情数据         │   │
│  │   - getCategories() 提供分类         │   │
│  └─────────────────────────────────────┘   │
│                                              │
│  插件不需要 UI,不依赖 Compose              │
└─────────────────────────────────────────────┘

核心概念

插件类型

Xime 目前只支持表情插件类型:

类型接口用途
EMOJIEmojiPlugin表情输入(颜文字、贴纸等)

重要特性

  • 插件只提供资源数据(EmojiItem)
  • 主应用负责展示和交互
  • 插件不需要 UI 代码,不依赖 Compose

开发插件步骤

1. 创建项目结构

my-kime-plugin/
├── build.gradle.kts
└── src/main/
    ├── AndroidManifest.xml
    ├── assets/           # 表情资源文件(可选)
    └── java/com/example/plugin/
        ├── PluginDeclaration.kt      # 空的 Activity(用于插件发现)
        └── MyPlugin.kt               # 实现 EmojiPlugin

2. 配置 build.gradle.kts

kotlin
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
}

android {
    namespace = "com.example.kime.plugin"
    compileSdk = 36
    
    defaultConfig {
        applicationId = "com.example.kime.plugin.myplugin"
        minSdk = 28
        targetSdk = 35
        versionCode = 1
        versionName = "1.0.0"
    }
    
    buildTypes {
        release {
            // 推荐禁用混淆,避免 Kotlin stdlib 方法丢失
            isMinifyEnabled = false
            isShrinkResources = false
        }
    }
    
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    
    kotlin {
        compilerOptions {
            jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
        }
    }
}

dependencies {
    // compileOnly 依赖,插件运行时由 PluginClassLoader 加载
    compileOnly(project(":plugin-core"))
    
    // 如果需要加载图片,添加 coil(implementation)
    implementation("io.coil-kt:coil-compose:2.5.0")
}

关键点

  • compileOnly(project(":plugin-core")) - 插件核心接口
  • isMinifyEnabled = false - 推荐禁用混淆,避免 Kotlin stdlib 方法丢失
  • 即使禁用混淆,某些 Kotlin stdlib 方法仍可能被宿主应用的 R8 规则裁剪,详见插件可用的 API 范围
  • 不需要 Compose 依赖(插件无 UI)

3. 配置 AndroidManifest.xml

xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    
    <application
        android:allowBackup="false"
        android:label="@string/app_name"
        android:supportsRtl="true">
        
        <!-- 插件声明 Activity(必须) -->
        <activity
            android:name=".PluginDeclaration"
            android:exported="true">
            <intent-filter>
                <action android:name="com.kingzcheung.xime.plugin.EXTENSION" />
                <category android:name="android.intent.category.DEFAULT" />
            </intent-filter>
        </activity>
        
        <!-- 插件元数据 -->
        <meta-data
            android:name="plugin.entryClass"
            android:value="com.example.plugin.MyPlugin" />
        
        <meta-data
            android:name="plugin.description"
            android:value="提供精选表情" />
        
        <meta-data
            android:name="plugin.type"
            android:value="emoji" />
        
    </application>

</manifest>

关键点

  • PluginDeclaration Activity 用于插件发现
  • plugin.entryClass 指定插件入口类
  • plugin.type 必须是 emoji

4. 实现插件入口类

kotlin
package com.example.plugin

import android.content.Context
import android.util.Log
import com.kingzcheung.xime.plugin.core.api.EmojiItem
import com.kingzcheung.xime.plugin.core.api.EmojiPlugin
import com.kingzcheung.xime.plugin.core.api.PluginIcon
import com.kingzcheung.xime.plugin.core.model.PluginContext
import java.io.File
import java.util.zip.ZipFile

class MyPlugin : EmojiPlugin {
    
    private var pluginContext: PluginContext? = null
    private var emojiList: List<EmojiItem> = emptyList()
    
    companion object {
        private const val TAG = "MyPlugin"
    }
    
    override fun onLoad(context: PluginContext) {
        this.pluginContext = context
        Log.d(TAG, "Plugin loaded: ${context.pluginInfo.id}")
        
        val filesDir = context.application.filesDir
        
        // 加载表情数据
        loadEmojis(filesDir, context.pluginInfo.path)
        
        Log.d(TAG, "Loaded ${emojiList.size} emojis")
    }
    
    override fun onUnload() {
        emojiList = emptyList()
        pluginContext = null
        Log.d(TAG, "Plugin unloaded")
    }
    
    private fun loadEmojis(filesDir: File, apkPath: String?) {
        val emojis = mutableListOf<EmojiItem>()
        
        // 从 APK assets 加载表情
        val actualApkPath = apkPath ?: pluginContext?.application?.applicationInfo?.sourceDir
        if (actualApkPath != null) {
            try {
                ZipFile(File(actualApkPath)).use { zip ->
                    zip.entries().asSequence()
                        .filter { it.name.startsWith("assets/emojis/") && !it.isDirectory }
                        .forEach { entry ->
                            val fileName = entry.name.substringAfter("assets/emojis/")
                            emojis.add(
                                EmojiItem(
                                    id = "emoji_$fileName",
                                    displayText = fileName,
                                    insertText = fileName,
                                    imageUrl = null, // 或本地文件路径
                                    category = "默认"
                                )
                            )
                        }
                }
            } catch (e: Exception) {
                Log.e(TAG, "Failed to load emojis", e)
            }
        }
        
        emojiList = emojis
    }
    
    override suspend fun getEmojis(
        category: String?, 
        searchText: String?, 
        topK: Int
    ): List<EmojiItem> {
        val filtered = if (searchText.isNullOrEmpty()) emojiList
        else emojiList.filter { 
            it.displayText.contains(searchText) || it.insertText.contains(searchText)
        }
        return filtered.take(topK)
    }
    
    override suspend fun getCategories(): List<String> {
        return emojiList.map { it.category }.distinct()
    }
    
    override fun getIcon(): PluginIcon? = PluginIcon(assetName = "icon.webp")
    
    // 或者使用文本图标:PluginIcon(text = "🐰")
    
    // hasSettings() 默认返回 false,不需要设置界面
    // openSettings() 默认空实现
}

5. 实现空的 PluginDeclaration

kotlin
package com.example.plugin

import android.app.Activity
import android.os.Bundle

class PluginDeclaration : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 空实现,用于插件发现
    }
}

6. 插件可用的 API 范围

插件通过宿主应用的 PluginClassLoader 加载,Kotlin stdlib 来自宿主应用。 宿主应用的 ProGuard/R8 规则决定了哪些 Kotlin stdlib 方法会保留。

宿主应用保留的 Kotlin stdlib 包

宿主应用明确保留了以下 Kotlin stdlib 包,插件可以安全使用

说明
kotlin.jvm.internalJVM 内部实现(如 Intrinsics
kotlin.collections集合操作(listOfmapOffiltermap 等)
kotlin.text字符串处理(splitreplacecontains 等)
kotlin.comparisons比较操作(compareBythenBy 等)
kotlin.ranges范围操作(1..10downTo 等)
kotlin.sequences序列操作(sequenceOfasSequence 等)

插件不可用的 Kotlin stdlib 功能

以下 Kotlin stdlib 包未在宿主应用中保留,插件应避免使用,否则可能在运行时抛出 NoSuchMethodError

替代方案
kotlin.io(如 readByteswriteText 等文件操作)使用 java.io.* 标准库
kotlin.reflect(如 memberPropertiescast 等反射操作)使用 java.lang.reflect.*
kotlin.math(如 sincos 等数学函数)使用 java.lang.Mathkotlin.math.*(需自行保留)
kotlin.system(如 measureTimeMillis避免使用
kotlin.concurrent(如 Thread 扩展函数)使用 java.util.concurrent.*
kotlin.random(如 Random使用 java.util.Random
kotlin.time(如 measureTimeDuration使用 System.currentTimeMillis()
kotlinx.*(协程、序列化等)如需使用请自行打包在插件 APK 中

插件 ProGuard 规则

如果启用混淆,不需要再保留 Kotlin stdlib(由宿主应用负责),只需保留插件自身的类和入口:

proguard
# Plugin ProGuard rules
-dontobfuscate
-optimizations !class/merging/*

# 保留插件自身代码
-keep class com.example.plugin.** { *; }
-keepattributes SourceFile,LineNumberTable

推荐:保持 isMinifyEnabled = false(禁用混淆),这是最简单可靠的做法。

插件数据结构

EmojiItem

kotlin
data class EmojiItem(
    val id: String,          // 唯一标识
    val displayText: String, // 显示文本
    val insertText: String,  // 插入文本
    val imageUrl: String?,   // 图片 URL(本地路径或网络 URL)
    val category: String     // 分类名称
)

PluginIcon

kotlin
data class PluginIcon(
    val text: String? = null,    // 表情符号文本(如 "🐰")
    val assetName: String? = null // assets 中的图标文件名(如 "icon.png")
)

插件图标两种方式:

  1. 文本图标PluginIcon(text = "🐰")
  2. 图片图标PluginIcon(assetName = "icon.webp") - 将图标放在 assets/icon.webp

主应用自动从插件 APK 提取图片图标。

PluginContext

kotlin
data class PluginContext(
    val application: Application,    // 宿主 Application
    val pluginInfo: PluginInfo,      // 插件信息
    val dataDir: File                // 插件数据目录
)

安装和测试

构建

bash
./gradlew assembleDebug

清除插件数据(调试用)

bash
./gradlew clearPlugins      # 清除插件文件
./gradlew uninstallApp      # 完全卸载主应用

安装顺序

bash
# 1. 卸载旧版本
adb uninstall com.kingzcheung.xime
adb uninstall com.example.kime.plugin.myplugin

# 2. 安装新版本
adb install app/build/outputs/apk/debug/Xime-xxx.apk
adb install my-plugin/build/outputs/apk/debug/my-plugin-xxx.apk

常见问题

1. ClassNotFoundException 或 NoSuchMethodError

原因

  • 插件使用了宿主应用未保留的 Kotlin stdlib 方法(如 kotlin.iokotlin.math 等),R8 裁剪后运行时找不到
  • 插件启用了混淆导致自身类名或方法被重命名

解决

  1. 禁用混淆:isMinifyEnabled = false(推荐)
  2. 检查代码是否使用了不可用的 Kotlin stdlib 包,改用 Java 标准库替代
  3. 如需保留特定 Kotlin 方法,可在插件自身添加 ProGuard 规则,但需注意宿主应用不一定包含这些方法

2. 插件无法发现

原因:AndroidManifest intent-filter 配置错误

解决:检查 <action android:name="com.kingzcheung.xime.plugin.EXTENSION" />

3. 插件加载失败

原因:插件入口类路径错误

解决

  • 检查 plugin.entryClass 元数据
  • 确保类名完整:com.example.plugin.MyPlugin

4. 表情数据未显示

原因

  • getEmojis() 返回空列表
  • 主应用未启用插件

解决

  • 检查 loadEmojis() 实现
  • 在主应用设置中启用插件

现有插件示例

插件类型特点
kaomojiEMOJI预定义颜文字数据
meme-bunnyEMOJI恶搞兔表情包(从 APK assets 加载)

参考文档

版本兼容

  • 插件 compileSdk 应与主应用一致(36)
  • 插件 targetSdk 应 ≥ 主应用 minSdk(28)
  • Kotlin 版本应与主应用一致(2.3.20)

基于 GPLv3 许可发布