构建 Kotlin Multiplatform 与 Webpack 集成的实时热重载开发工作流


我们的技术栈选型在初期看起来颇为理想:使用 Kotlin Multiplatform (KMP) 编写核心业务逻辑与数据模型,编译为 JavaScript 模块,供前端的 React + MobX 应用消费。这个决策的目标很明确——复用代码,保证跨平台逻辑的一致性。然而,当第一个原型跑起来时,一个巨大的开发效率鸿沟暴露无遗。

任何对 .kt 文件的修改,都必须经过一个迟钝且完全手动的流程:

  1. 切换到终端,运行 ./gradlew :shared:jsBrowserDevelopmentWebpack
  2. 等待 Gradle 守护进程唤醒、配置、编译,最后生成 JavaScript 输出。
  3. 切换回另一个终端,重启 webpack-dev-server 以加载新的 KMP 模块。
  4. 等待 Webpack 重新打包,浏览器刷新。

整个反馈周期轻易超过30秒。这在现代前端开发中是完全无法接受的。前端工程师习惯了亚秒级的热模块替换 (HMR),而 KMP 的引入,让我们的开发体验倒退了十年。问题核心在于,两个独立的构建系统——Gradle 和 Webpack——彼此毫无感知。

graph TD
    A[开发者修改 .kt 文件] --> B{手动运行 Gradle 编译};
    B --> C{等待 KMP 模块产物生成};
    C --> D{手动重启 Webpack Dev Server};
    D --> E{等待 Webpack 重新打包};
    E --> F[浏览器整页刷新];

    style B fill:#f9f,stroke:#333,stroke-width:2px
    style D fill:#f9f,stroke:#333,stroke-width:2px

初步构想是利用 Gradle 的持续构建模式 (--continuous)。但这治标不治本。它确实能自动重新编译 Kotlin 代码,但 Webpack 的 watch 机制并不知道 KMP 模块的产物目录发生了变化,更不用说触发 HMR 了。我们需要一个真正的桥梁,让 Webpack 能够“理解”并“驱动”KMP 的构建流程,并将其无缝整合进自己的 HMR 工作流中。

方案定格在开发一个自定义 Webpack 插件。这个插件的职责是:

  1. 将 Kotlin 源文件目录 (src/commonMain/kotlin, src/jsMain/kotlin) 加入 Webpack 的监听列表。
  2. 当监听到 .kt 文件变更时,异步触发一次精准的 Gradle 构建任务。
  3. 确保 Gradle 构建完成后,通知 Webpack 的编译进程,让其能够捡起最新的产物并触发 HMR。

基础项目结构

在深入插件实现之前,先看一下项目的核心配置。

KMP 模块 (shared/build.gradle.kts):

// shared/build.gradle.kts
plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidLibrary) // For Android target, not relevant here but common
}

kotlin {
    // ... other targets like jvm, android, ios
    js(IR) {
        browser {
            // This is critical. It configures Gradle to generate JS files
            // suitable for consumption by Webpack.
            webpackTask {
                output.libraryTarget = "commonjs2"
            }
            binaries.executable()
        }
    }

    sourceSets {
        val commonMain by getting {
            dependencies {
                // Common dependencies
            }
        }
        val jsMain by getting {
            dependencies {
                // JS-specific dependencies
            }
        }
    }
}

关键在于 webpackTask 的配置,它指示 Gradle 生成一个 commonjs2 格式的模块,这是 Webpack 消费的标准格式之一。编译后的产物会出现在 shared/build/js/packages/shared/kotlin/ 目录下。

前端项目 (webpack.config.js):

// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

// The path to the KMP module's output.
const kmpModulePath = path.resolve(__dirname, 'shared/build/js/packages/shared/kotlin/shared.js');

module.exports = {
  mode: 'development',
  entry: './src/index.tsx',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js'],
    alias: {
      // Create an alias for easier import.
      'kmp-shared-module': kmpModulePath,
    },
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
    }),
  ],
  devServer: {
    static: path.join(__dirname, 'dist'),
    compress: true,
    port: 9000,
    hot: true,
  },
};

这里我们用 resolve.alias 将 KMP 模块的复杂路径映射成一个易于引用的别名 kmp-shared-module

第一版插件:暴力触发构建

我们的第一个尝试是创建一个简单的插件,在 Webpack 启动时执行一次 Gradle 构建。

// KMPWatchPlugin.v1.js
const { exec } = require('child_process');

class KMPWatchPlugin {
  constructor(options) {
    this.options = options || {};
    this.gradleTask = this.options.gradleTask || ':shared:jsBrowserDevelopmentWebpack';
  }

  apply(compiler) {
    compiler.hooks.beforeRun.tapAsync('KMPWatchPlugin', (compiler, callback) => {
      console.log('KMPWatchPlugin: Running initial Gradle build...');
      // A common mistake is using execSync, which blocks the entire Webpack process.
      // We must use the async version.
      exec(`./gradlew ${this.gradleTask}`, (error, stdout, stderr) => {
        if (error) {
          console.error(`Gradle build failed: ${error}`);
          // Pass the error to Webpack to halt the compilation.
          callback(error);
          return;
        }
        console.log(stdout);
        console.error(stderr);
        console.log('KMPWatchPlugin: Initial Gradle build finished.');
        callback();
      });
    });
  }
}

module.exports = KMPWatchPlugin;

webpack.config.js 中引入并使用它:

// ...
const KMPWatchPlugin = require('./KMPWatchPlugin.v1.js');

module.exports = {
  // ...
  plugins: [
    // ...
    new KMPWatchPlugin({ gradleTask: ':shared:jsBrowserDevelopmentWebpack' }),
  ],
  // ...
};

这个版本解决了启动时手动编译的问题,但它完全没有监听文件变化的能力。任何对 Kotlin 代码的修改依然需要重启 Webpack。

第二版插件:集成 Webpack 的 Watch 机制

为了让 Webpack 能够监听到 Kotlin 文件的变化,我们需要将 KMP 源码目录添加到 Webpack 的监听依赖中。Webpack 插件系统中的 compiler.hooks.afterCompile 是一个合适的时机。

// KMPWatchPlugin.v2.js
const { exec } = require('child_process');
const path = require('path');

class KMPWatchPlugin {
  constructor(options) {
    this.options = options || {};
    this.gradleTask = this.options.gradleTask;
    this.kmpSrcDirs = this.options.srcDirs.map(dir => path.resolve(compiler.context, dir));
    this.isBuilding = false; // A simple lock to prevent concurrent builds.
  }

  apply(compiler) {
    // Add KMP source directories to Webpack's file dependencies.
    compiler.hooks.afterCompile.tap('KMPWatchPlugin', (compilation) => {
        this.kmpSrcDirs.forEach(dir => {
            compilation.contextDependencies.add(dir);
        });
    });

    // The core logic: hook into the watcher.
    compiler.hooks.watchRun.tapAsync('KMPWatchPlugin', (compiler, callback) => {
      if (this.isBuilding) {
        callback();
        return;
      }

      // `compiler.modifiedFiles` is a Set of changed file paths since the last compilation.
      const changedFiles = Array.from(compiler.modifiedFiles || []);
      const isKotlinFileChanged = changedFiles.some(file => 
        this.kmpSrcDirs.some(dir => file.startsWith(dir))
      );

      if (!isKotlinFileChanged) {
        callback();
        return;
      }
      
      this.isBuilding = true;
      console.log('KMPWatchPlugin: Kotlin source changed. Triggering Gradle build...');
      
      exec(`./gradlew ${this.gradleTask}`, (error, stdout, stderr) => {
        this.isBuilding = false;
        if (error) {
          console.error(`\x1b[31m%s\x1b[0m`, `Gradle build failed: ${error.message}`); // Use colors for errors
          console.error(stderr);
          // Don't halt Webpack, just log the error. The old bundle is still valid.
          // Let the developer see the error and fix it.
          callback();
          return;
        }
        console.log(`\x1b[32m%s\x1b[0m`, `KMPWatchPlugin: Gradle build successful.`);
        // Don't need to do anything else. Webpack's own watcher will detect
        // the changed output file in the `shared/build` directory and trigger
        // a new compilation cycle automatically.
        callback();
      });
    });
  }
}

module.exports = KMPWatchPlugin;

更新 webpack.config.js 以使用新插件并传入源目录:

// webpack.config.js
// ...
const KMPWatchPlugin = require('./KMPWatchPlugin.v2.js');

module.exports = {
  // ...
  plugins: [
    // ...
    new KMPWatchPlugin({
      gradleTask: ':shared:jsBrowserDevelopmentWebpack',
      srcDirs: ['./shared/src/commonMain/kotlin', './shared/src/jsMain/kotlin'],
    }),
  ],
  // ...
};

现在,工作流已经大为改善。修改 .kt 文件并保存,终端会显示 KMP 插件触发了 Gradle 构建,构建成功后,Webpack 会自动检测到 KMP 产物的变化,并进行一次热重载。

graph TD
    subgraph Webpack Dev Server Process
        A[开发者修改 .kt 文件] --> B(Webpack Watcher 侦测到变更);
        B --> C{KMPWatchPlugin};
        C --> D[异步执行 ./gradlew ...];
    end

    subgraph Gradle Process
        D --> E[Gradle 编译 Kotlin 代码];
        E --> F[生成新的 JS 产物];
    end

    subgraph Webpack Dev Server Process
        F -- 文件变更 --> G(Webpack Watcher 侦测到 KMP 产物变更);
        G --> H[Webpack 重新打包];
        H --> I[HMR 推送至浏览器];
    end
    
    style C fill:#ccf,stroke:#333,stroke-width:2px

数据桥接:将 KMP 模型与 MobX 状态连接

自动化构建只是第一步。真正的挑战在于如何在前端优雅地消费 KMP 模块,并将其融入 MobX 的响应式系统。

假设我们在 KMP 中定义了一个用户模型:

// shared/src/commonMain/kotlin/com/example/model/User.kt
package com.example.model

import kotlin.js.JsExport

@JsExport // This annotation makes the class visible to JavaScript.
data class User(
    val id: Long,
    var name: String,
    var email: String
) {
    fun getFormattedInfo(): String {
        return "User(id=$id, name='$name')"
    }
}

@JsExport
object UserFactory {
    fun createDefaultUser(): User {
        return User(1L, "John Doe", "[email protected]")
    }
}

@JsExport 是关键,它告诉 Kotlin/JS 编译器将这个类和对象暴露出去。

在前端,我们需要一个 MobX Store 来管理这个 User 对象的状态。一个常见的错误是直接将 KMP 对象实例赋值给 store 的属性。

// src/stores/UserStore.ts (Incorrect approach)
import { makeAutoObservable } from 'mobx';
import { User, UserFactory } from 'kmp-shared-module';

class UserStore {
  // This will NOT be reactive. MobX cannot observe properties on plain JS objects
  // that are compiled from Kotlin.
  user: User | null = null;

  constructor() {
    makeAutoObservable(this);
  }

  loadUser() {
    this.user = UserFactory.createDefaultUser();
  }
  
  // This mutation will not trigger any reaction in React components.
  updateUserName(name: string) {
      if (this.user) {
          this.user.name = name;
      }
  }
}

export const userStore = new UserStore();

这里的 user.name = name 只是修改了一个普通 JavaScript 对象的属性。MobX 的响应式系统对此一无所知。

正确的做法是创建一个“适配器”或在 Store 内部将 KMP 数据模型的可观察属性进行“代理”。我们将 KMP 对象视为纯粹的数据载体,而响应式状态由 MobX 在 TypeScript/JavaScript 层完全接管。

// src/stores/UserStore.ts (Correct, production-grade approach)
import { makeAutoObservable, runInAction } from 'mobx';
// Import the TYPE, not the implementation, for better separation.
import type { User } from 'kmp-shared-module'; 
import { UserFactory } from 'kmp-shared-module';

// A dedicated logger for store actions for better traceability
const log = (message: string, ...args: any[]) => console.log(`[UserStore] ${message}`, ...args);

class UserStore {
  userId: number | null = null;
  userName: string = '';
  userEmail: string = '';
  isLoading: boolean = false;

  private kmpUserInstance: User | null = null;

  constructor() {
    // We explicitly define which properties are observable, computed, or actions.
    // This is more robust than makeAutoObservable for complex stores.
    makeAutoObservable(this, {}, { autoBind: true });
  }

  // Action to load user data, wrapping the KMP factory call.
  async loadUser() {
    log('Loading user...');
    this.isLoading = true;
    try {
      // Simulate an async operation
      await new Promise(resolve => setTimeout(resolve, 500));
      const userFromKmp = UserFactory.createDefaultUser();

      // All state mutations should be wrapped in `runInAction`
      // especially after an `await` call.
      runInAction(() => {
        this.syncWithKmpInstance(userFromKmp);
        this.isLoading = false;
        log('User loaded successfully.', this.kmpUserInstance);
      });
    } catch (error) {
        runInAction(() => {
            this.isLoading = false;
            // Proper error handling is crucial.
            log('Failed to load user.', error);
        });
    }
  }
  
  // Action to update user's name. It updates MobX state first,
  // then synchronizes back to the KMP instance.
  updateUserName(newName: string) {
    if (!this.kmpUserInstance) {
        log('Cannot update name, user not loaded.');
        return;
    }
    log(`Updating user name to "${newName}"`);
    this.userName = newName;
    this.kmpUserInstance.name = newName; // Update the underlying KMP object
  }

  // This method allows calling a function directly on the KMP object.
  getFormattedInfo(): string {
    if (this.kmpUserInstance) {
        // We can still leverage the business logic defined in Kotlin.
        return this.kmpUserInstance.getFormattedInfo();
    }
    return "No user loaded";
  }

  // Private helper to sync state from KMP object to MobX observables.
  private syncWithKmpInstance(kmpInstance: User) {
    this.kmpUserInstance = kmpInstance;
    this.userId = kmpInstance.id;
    this.userName = kmpInstance.name;
    this.userEmail = kmpInstance.email;
  }
}

export const userStore = new UserStore();

这个版本的 UserStore 将 KMP 的 User 对象视为一个内部的、非响应式的数据源。Store 自身维护一组 MobX 的 observable 属性 (userId, userName 等),并通过 syncWithKmpInstance 方法将 KMP 对象的数据同步到这些 observable 属性上。任何状态变更都通过 MobX 的 action 进行,这保证了 React 组件能够正确地重新渲染。

在 React 组件中消费

现在,React 组件可以像消费任何普通 MobX store 一样消费 userStore

// src/components/UserProfile.tsx
import React, { useEffect } from 'react';
import { observer } from 'mobx-react-lite';
import { userStore } from '../stores/UserStore';

export const UserProfile = observer(() => {
  useEffect(() => {
    userStore.loadUser();
  }, []);

  if (userStore.isLoading) {
    return <div>Loading profile...</div>;
  }

  if (!userStore.userId) {
    return <div>User not found.</div>;
  }

  const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    userStore.updateUserName(e.target.value);
  };

  return (
    <div>
      <h1>User Profile</h1>
      <p>ID: {userStore.userId}</p>
      <div>
        <label htmlFor="name">Name: </label>
        <input
          id="name"
          type="text"
          value={userStore.userName}
          onChange={handleNameChange}
        />
      </div>
      <p>Email: {userStore.userEmail}</p>
      <hr />
      <p>Info from KMP method: <code>{userStore.getFormattedInfo()}</code></p>
    </div>
  );
});

这个组件完全不知道 User 数据的源头是一个 Kotlin Multiplatform 模块。它只与 userStore 交互,状态管理的边界非常清晰。当你在输入框中修改名字时,updateUserName action 会被调用,MobX 驱动组件重新渲染,整个流程流畅且高效。

方案的局限性与未来展望

我们建立的这套工作流,虽然极大地提升了开发效率,但并非没有权衡。

首先,KMPWatchPlugin 依赖于通过 child_process 调用 Gradle。这会带来一定的进程启动开销。对于非常频繁的修改,这个延迟虽然比手动操作好得多,但仍不如原生 TypeScript 的 HMR 那么快。一个更激进的优化方向是探索使用 Gradle Tooling API,在 Webpack 插件进程中与一个长驻的 Gradle 守护进程直接通信,从而消除重复的进程启动开销。

其次,Kotlin 到 TypeScript 的类型映射并非完美。虽然 KMP 会生成 .d.ts 类型定义文件,但对于一些 Kotlin 特有的语言特性,如 sealed class、复杂的泛型或者某些标准库类型,转换后的 TypeScript 类型可能并不符合直觉。这要求团队在 KMP 与前端的接口层设计上投入更多精力,保持接口的简洁与类型友好。

最后,此方案的错误处理机制相对基础。Gradle 构建失败时,插件只是将错误打印到控制台。在大型团队中,可能需要更完善的错误上报机制,例如通过 Webpack 的 stats 对象将错误信息更明显地展示在开发服务器的覆盖层上。这需要对 Webpack 的 compilationstats 对象有更深入的理解和操作。


  目录