はてなブログAndroidアプリのエディタ実装

はじめに

こんにちは、アプリケーションエンジニアの id:jj1uzh です。

Recomposing Hatena Blog for Androidは、昨2025年にリニューアルされたはてなブログAndroidアプリにまつわる開発体験や技術詳細を解説するブログシリーズです。

前回は「Webエンジニアが半年Androidアプリ開発をやってみて感じたこと」でした。私もWebのみの経験からAndroidを始めたので、共感しながら読みました。

今回はアプリの実装に踏み込んで、ブログアプリの根幹であるエディタについて以下の観点で紹介します。

  • WebViewを用いたリッチテキストエディタの実装
  • 複数の記法の抽象化

この記事はブログアプリを題材としていますが、ComposeのUIとWebViewの連携やViewModel内で複数パターン実装の抽象化といったよくあるパターンについて解説していますので、ブログに限らずAndroidアプリ開発の一例として参考にしていただければと思います。

WebView上のリッチテキストエディタ: KotlinとJavaScriptの交信

はてなブログAndroidアプリは2026年7月現在、はてな記法・Markdown・見たまま記法の三種類の記法をサポートしています。 実装上は、プレーンテキストであるはてな記法・MarkdownにはComposeのTextFieldを、見たまま記法にはWebView上のリッチテキストエディタを使っています。

WebView上のリッチテキストエディタを使う際、エディタの状態とアプリのネイティブUIを連携させたり、文章をローカルに随時保存したりするには、 WebView内のJavaScriptとCompose世界(Kotlin)でやり取りをする必要があります。

ここからは、WebViewでリッチテキストエディタを用意し、KotlinとJavaScriptのやり取りによってアプリのネイティブUIと連携させる方法を紹介します。

リッチテキストエディタの初期化とインターフェースの用意

まずはリッチテキストエディタのHTMLを用意し、assetsディレクトリに置いておきます。

<!-- editor.html -->
...
<div id="placeholder"></div>
<script charset="utf-8" src='xxx_editor.js'></script>
<script>
    window.Editor = XxxEditor.init();
</script>
...

このHTMLファイルを、エディタ用のWebViewで読み込みます。

val webView = WebView(context).apply {
    webViewClient = object : WebViewClient() {
        val assetLoader = WebViewAssetLoader.Builder()
          .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(context))
          .build()

        override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest) =
          assetLoader.shouldInterceptRequest(request.url)
    }
    loadUrl("https://appassets.androidplatform.net/assets/editor.html")
}

アセットの読み込みに関して、詳しくは開発者ドキュメント1を参照してください。

これでWebView自体の準備は整い、Kotlinから触る口も用意されました。

KotlinからJavaScriptを呼び出す

先ほど作った val webViewevaluateJavascript2を呼び出すことで、WebView内でJavaScriptを実行可能です。

例えば、アプリのUIから選択範囲を太字に変更する、写真をアップロードしてそのURLを挿入する、といった機能はこの方法で実装できます。

webView.evaluateJavascript(
    "Editor.getContent()"
    // "Editor.setStyle(...)"
    // "Editor.insert(\"...\")"
) { result ->
    val text = (Json.parseToJsonElement(result) as? JsonPrimitive)?.content
    ...
}

なお、Kotlin側から任意の文字列をエディタに渡すなど、JavaScriptコードを動的に組み立てる場合は、エスケープ処理に気をつけましょう。

JavaScriptからKotlinを実行する

逆に、WebView内のJavaScriptからKotlinの関数を実行したい場合は、 WebView#addJavascriptInterface3 を使います。 例えば、テキストの変更ごとにKotlin上の関数を呼んで、変更内容や文字数などの情報を伝えることができます。

まずKotlin側で、JavaScriptから使えるインターフェースを用意します。

webView.addJavascriptInterface(
    object : Any() {
        @JavascriptInterface
        fun onInput(text: String, count: Int) {
          // ... 本文・文字数の更新を反映する処理 ...
        }
    },
    "NativeCallback"
)

こうすることで、JavaScriptから NativeCallback.onInput という関数でKotlinの関数を呼ぶことができるようになります。

Editor.editor.on('change', function (e) {
    NativeCallback.onInput(Editor.getContent(), Editor.getCharacterCount());
});

ただし、addJavascriptInterface は容易に脆弱性たり得るため、WebViewの遷移できるWebページを制限する、呼び出せるKotlin関数では引数を信頼しないなど、慎重に扱う必要があります。

複数の記法の抽象化

ここまでの実装で、Kotlinからは val webView に対する操作でもってWebView内のリッチテキストエディタとやり取りすることができるようになりました。

ところで、リッチテキストはWebViewで実装されている一方、プレーンテキストのはてな記法・MarkdownはComposeのTextFieldを使って実装していました。

画像のアップロードや投稿の処理は共通である一方で、編集中のテキストをコンポーネントから渡したり、アプリのUI(ひいてはViewModel)からテキストに変更を加える方法だけが、TextFieldとWebViewで異なっています。ここを抽象化して、実装をスッキリさせたくなります。

エディタのViewModelは、UI上のボタンなどの操作に対応して、次のようなインターフェースとなっています。

class EditorViewModel {
    fun onInput(text: String)
    fun decorate(d: Decoration)
    fun uploadImage(uri: Uri)
    fun publish()
    ...
}

抽象化で最低限必要なのは以下の二つです。

  • コンポーネント上でユーザーが編集したテキストを監視するFlow
  • 記法変更ボタンや画像アップロードなど、ViewModelを介してテキストを編集する関数

インターフェースとしては以下のような定義になります。

interface EditorState {
    val text: Flow<String>
    fun <T> edit(ops: Operations.() -> T): T
}

text はエディタ上のテキストを反映しているFlowで、EditorViewModel ではこのFlowを監視することでバックアップをとったり、投稿時に使用したりします。

class EditorViewModel {
    val editorState: EditorState
    val repository: XxxRepository

    init {
        editorState = ... // 初期化
        editorState.text.onEach { 
            backup(it) // 定期バックアップ
        }.launchIn(viewModelScope)
    }

    // 投稿
    suspend fun publish() {
        repository.publish(editorState.text.first())
    }
}

EditorState を用意するメリットは、今回のような複数実装の抽象化だけでなく、 text をSingle Source of Truthとして扱える点にもあります。

text は以下のように実装できます。

class TextEditorState(val textFieldState: TextFieldState) : EditorState {
    override val text = snapshotFlow { textFieldState.text.toString() }
}

class WebViewEditorState(val webview: WebView) : EditorState {
    private val _text = MutableStateFlow("")
    override val text: Flow<String> = _text.asStateFlow()

    init {
        // 先述のaddJavascriptInterfaceを使い、JS側からの変更通知でStateFlowを更新する
        webview.addJavascriptInterface(
            object : Any() {
                @JavascriptInterface
                fun onInput(text: String) { _text.update { text } }
            },
            "NativeCallback",
        )
    }
}

次にedit 関数です。

interface EditorState内のメソッド定義 fun <T> edit(ops: Operations.() -> T): T は、TextFieldState4edit 関数を参考にしています。

Operations.() -> T はレシーバー付きの関数型リテラル5と呼ばれる記法で、edit に渡す関数はOperationsを暗黙のレシーバーとしてそのメソッドを直接呼び出すことができます。例えばTextFieldStateの例を見ると、

// https://developer.android.com/reference/kotlin/androidx/compose/foundation/text/input/TextFieldState#edit(kotlin.Function1)
val state = TextFieldState("hello world!")
state.edit {
    // Insert a comma after "hello".
    insert(5, ",") // = "hello, world!"
    ...
}

のように、TextFieldBufferinsert 関数を直接呼び出せていて、簡単にDSLを定義できるというわけです。

今回定義する EditorState は、TextFieldStateedit をラップしつつWebViewに対しても同じ記法(+はてなブログ用の操作)でViewModelから呼び出せるようにすることが目的となります。まず、必要な操作をinterfaceにまとめます。

interface Operations {
    fun insert(text: String, breaks: Boolean)
    fun decorate(d: Decoration)
    ...
}

TextFieldとWebViewそれぞれで Operations の実装を用意します。

data class TextOperations(val textFieldState: TextFieldState, val syntax: Syntax) : Operations {
    override fun insert(text: String, breaks: Boolean) {
        textFieldState.edit { insert(...) }
    }
    override fun decorate(d: Decoration) = when (d) {
      Bold -> when (syntax) {
        Markdown -> ...
        Hatena -> ...
      }
    }
}

data class WebViewOperations(val webview: WebView) : Operations {
    override fun insert(text: String, breaks: Boolean) {
        webview.evaluateJavascript("Editor.insertContent('$text')", null)
    }
    override fun decorate(d: Decoration) = when (d) {
      Bold -> webview.evaluateJavascript("Editor.boldCommand()", null)
      Heading -> webview.evaluateJavascript("Editor.headingCommand()", null)
      ...
    }
}

EditorState#edit から簡単に使えるようになります。

class TextEditorState(val textFieldState: TextFieldState, val syntax: Syntax) : EditorState {
    override fun <T> edit(ops: Operations.() -> T): T = TextOperations(textFieldState, syntax).ops()
}
class WebViewEditorState(val webview: WebView) : EditorState {
    override fun <T> edit(ops: Operations.() -> T): T = WebViewOperations(webview).ops()
}

EditorState#edit を実装できたので、ViewModelでエディタ操作系のメソッドを実装できるようになりました。

class EditorViewModel {
    val editorState: EditorState
    val repository: XxxRepository

    fun onInput(text: String) {
        editorState.edit { insert(text, breaks = false) }
    }

    fun decorate(d: Decoration) {
        editorState.edit { decorate(d) }
    }

    fun uploadImage(uri: Uri) {
        val uploadedUrl = repository.uploadImage(uri)
        editorState.edit { insert(uploadedUrl, breaks = true) }
    }
}

スッキリ実装できましたね〜

まとめ

いかがでしたか。読者の皆様がAndroidアプリを書くとき、この内容がお役に立つことがあれば幸いです。

はてなブログAndroidアプリは、リリース後もパフォーマンスや使い勝手の改善を続けています。ブログを書くときは、ぜひアプリを使ってみてください!

また、はてなブログAndroidアプリリニューアルにまつわるブログシリーズRecomposing Hatena Blog for Androidは、引き続き投稿されていく予定です。お楽しみに。