こんにちは。開発本部の CLINICS カルテ の開発を担当している @seka です。メドレーでは貴重な (?) エンジニアの若者枠として日々奮闘しております。
今回、開発本部で定期的に開催している勉強会「TechLunch」で、「Android で HTML をいい感じで表示できるようにした話」 という題で発表しましたので、その内容について紹介させていただきます。
1. きっかけ
医師たちがつくるオンライン医療事典 MEDLEY (メドレー) をアプリ化することができるか検証してみて欲しいという相談を受け、Android のモックを作成することになりました。
アプリらしい UI を目指して開発を進めていたのですが、MEDLEY では病気記事が CMS などに見られるような HTML 形式で管理されており、そのまま表示してもイメージしたようなデザインが実現できないかも…という課題に直面しました。

2. HTML を表示するまで
いくつかのステップに分解して、HTML の要素を Android のコンポーネントに置換していくことで対応する方針を立て、その実現可能性を調べました。大まかなフローはこんな感じです。
- 事前に HTML と Android のコンポーネントの対応を決める
 - HTML を Kotlin でも扱える形式に変換する
 - HTML の要素を探索する
 - マッチした要素を Android のコンポーネントで置き換える それぞれのフローについて解説していきます。
 
1. 事前に HTML と Android のコンポーネントの対応表を作る
簡単にですが下表のような対応を決めます。(検証段階だったので、いくつかの要素は省略しています)

2. HTML を Kotlin で扱える形式に変換する
HTML を生の String として操作するのは流石に辛いので、Kotlin でも扱いやすいような形に変換します。
今回は jhy/jsoup という便利そうなライブラリを見つけたため、これを利用することにしました。
jsoup は Java 製の HTML パーサーで example にもあるように HTML を Kotlin でも扱いやすい形式に変換することができます。
下記の例は Wikipedia Root Node から小要素を探索していく様子です。
HTML タグの情報・親要素・内容のテキストを利用したいため保持しておきます。
val doc = Jsoup.connect("https://en.wikipedia.org/").get()
doc.childNode(1).childNode(2).childNode(1)
/*
{Element@5739} "<div id="mw-page-base" class="noprint"></div>"
  attributes = {Attributes@5755}
  baseUri = "https://en.wikipedia.org/wiki/Main_Page"
  childNodes = {Collections$EmptyList@5720}  size = 0
  shadowChildrenRef = null
  tag = {Tag@5756}
  canContainInline = true
  empty = false
  formList = false
  formSubmit = false
  formatAsBlock = true
  isBlock = true
  preserveWhitespace = false
  selfClosing = false
  tagName = "div"
  parentNode = {Element@5729}
  siblingIndex = 1
*/
3. HTML の要素を探索する
HTML の要素を探索する 再帰を利用して Root Node から小要素を探索し、jsoup で置き換えていきます。
class HTMLConverter(private val html: String) {
  fun parse() {
    val body = Jsoup.parse(html).normalise().body()
    inspect(body, ElementViewHolder())
  }
    private fun inspect(parent: Element) {
      parent.children().forEach {
        if (parent.children.isEmpty()) {
          return@forEach
        }
        inspect(it)
      }
}
4. マッチした要素を Android のコンポーネントで置き換える
事前に定義した方法に従って、HTML をそれぞれ対応する Android コンポーネントに変換していきます。
Img 要素の場合:
Glide を利用して画像を非同期で取得し ImageView にラップする。
private fun convertImage(el: Element): View? {
  val url = HttpUrl.parse(el.absUrl("src")) ?: return null return ImageView(context).apply {
    layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
    val header = LazyHeaders.Builder().addHeader("Content-Type", "image/bmp").build()
    val glideUrl = GlideUrl(url.url(), header)
    Glide.with(context)
        .asBitmap()
        .load(glideUrl)
        .into(this)
}
Table 要素の場合:
Table と TableRow をそれぞれ作成して合成する。
private fun parseTable(el: Element): View? {
  return TableLayout(context).apply {
    layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
  }
}
private fun createTableRow(): View? {
  return TableRow(context)
}
private fun composeTableRow(self: TableRow, parent: TableLayout): Boolean {
  table.addView(self)
  return true
}
Android Emulator で表示してみる
上記の方法で作成したモックがこちらになります。

ただ HTML を表示するだけであれば WebView を利用すればもっと楽に表示することもできますが、UI をカスタマイズしたい場合にはデータに変更を加えなければいけません。
今回のようなアプローチをとることで、もともとある HTML のデータを壊すことなく Android に適したデザインを実現しやすくなったのではないでしょうか。
さいごに
コミュ障故に人前で話すことを避けてきたのですが、 TechLunch という機会をいただき Android なネタを発表をさせていただきました。
今回作成したモックは技術検証目的であるためリリースの予定はありませんが、発表のために書いた実装例は seka/HTMLConverter.kt として公開しています。
このようなアプローチを取る機会は少ないと思いますが、読んでくださった方の力になれれば幸いです!