"What I cannot create, I do not understand. Know how to solve every problem that has been solved." -- Richard Feynman
Widgetを組み合わせるだけでさまざまな画面を作ることができるFlutterですが、なぜそのようなことが可能なのでしょうか?本書ではFlutterを自身で1から実装し直し、Flumeという新たなUIフレームワークを作ります。再実装を通じて、UIフレームワークを支える複雑かつ壮麗な世界の理解を目指します。
Flutter自体はすでに巨大かつ複雑なソフトウェアであり、隅々まで実装するのは困難です。そのため、Flumeでは本質的に重要な機能のみをピックアップして説明・実装することにします。機能を絞るため、もちろん実用に足るフレームワークを作ることは叶いません。しかし、UIの管理手法というのはFlutterだけでなく、Webフレームワークやブラウザー、AndroidやiOSなどのネイティブアプリでも共通して利用されています。まずはシンプルなFlumeの仕様を理解することで、本家Flutterや他のフレームワークのコードを理解する後押しになればよいと考えています。
・Kotlin/JVM
・Kotlin Gradle
・LWJGL+OpenGL+GLFW
・Skiko
Flumeを実装するに当たり、例として紹介するのは、すべてKotlin/JVMでの実装になります。しかし描画側のライブラリーは比較的多くの言語や環境で使えるものであるため、好きな言語・バックエンドで書くことも可能だと考えています(もし別言語で実装した場合は共有してくれると嬉しいです)。
1巻では、FlutterがWidgetをどのように内部で処理し、画面に表示しているのかというところを追い、自身で再実装します。
UIフレームワークの旅は、画面を表示するところから始まります。
0から画面を作るというとき、本来ならばOpenGLなどの低レイヤなグラフィックスライブラリーを使うべきなのかもしれません。が、そこから始めてしまうとCGの学習が大きくのしかかってくるため、少しだけ横着して、Skiaというライブラリーから出発することにします。
本章の実装のコード・差分はこちらに掲載しています。
https://github.com/organic-nailer/flume/commit/50065b
Skia1は2Dのグラフィックスライブラリーで、Googleを中心としてオープンソースで開発が続けられています。ChromiumやAndroid、Flutterなど多くの巨大プロダクトで採用されているため、利用側としては安定して使える安心なライブラリーです。環境を問わず同じグラフィックを提供できるということで、FlutterやXamarin.Forms、Compose for Desktop等、クロスプラットフォームのバックエンドとしての採用も目立ちます。
Skiaを使う一番の理由は、Flutterで使われているためです。簡単な描画はFlutterのCustomPaintなどで使うものとほとんど同じであり、Flutter使いとしては扱いやすいものになっています。
また、Skiaは各言語のバインディングが豊富なのも特徴です。観測した中でも
・Skia: C++
・Skija: Java
・Skiko: Kotlin
・skia-python: Python
・SkiaSharp: C#/.NET
・rust-skia: Rust
・CanvasKit: WASM
・node-skia: Node.js
・SkiaKit: Swift
といったものがあります。今回はKotlinで書くので、Skiko2を利用します。SkikoはJetbrainsの管理するプロジェクトで、Compose for Desktopなどにも採用されています。安心ですね!
Skiaはバックエンドとして、OpenGLやVulkanなど複数のライブラリーが選択できます。今回はJVMでのOpenGLとウィンドウ表示の提供元として、LWJGL3というライブラリーを利用します。
ゲーム用のライブラリーなので、多少大きいです。OpenGL(GLFW)さえ使えれば、何のライブラリーでもあまり変わらない実装ができると思います。
さて、早速実装を始めましょう。Intellij IDEAでKotlinのプロジェクトを作成します。Intellij IDEA Community版4であれば、無料で使うことができます。
起動画面の[New Project]から新規プロジェクトの画面に行き、
・New Project
・Language: Kotlin
・Build system: Gradle
・Gradle DSL: Kotlin
・Add sample code
を選択します。プロジェクト名はflume-なんとかにしておきましょう。
[Create]を押すと、プロジェクトが作られます。しばらくすると、src/main/kotlin/Main.ktのmain関数の左に緑矢印が出てくるので、そこから実行できます。
Hello Worldが出力されるのを確認しましょう。これでひとまず、プロジェクトの用意は完了です。
続いて、ウィンドウが表示できるようにしましょう。まず、LWJGLとSkikoを導入します。
プロジェクトのルートにあるbuild.gradle.ktsへ次の記述をしましょう。
plugins {
kotlin("jvm") version "1.6.10"
}
group = "dev.fastriver"
version = "1.0-SNAPSHOT"
val skArtifact = "skiko-awt-runtime-windows-x64" // x64 Windowsの場合
// val skArtifact = "skiko-awt-runtime-macos-x64" // intel Macの場合
// val skArtifact = "skiko-awt-runtime-linux-arm64" // arm Linuxの場合
val skVersion = "0.7.18"
val lwjglVersion = "3.3.1"
val lwjglNatives = "natives-windows" // x64 Windowsの場合
// val lwjglNatives = "natives-macos" // intel Macの場合
// val lwjglNatives = "natives-linux-arm64" // arm Linuxの場合
repositories {
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
dependencies {
testImplementation(kotlin("test"))
api("org.jetbrains.skiko:${skArtifact}:${skVersion}")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1")
implementation("org.lwjgl:lwjgl:$lwjglVersion")
implementation("org.lwjgl:lwjgl-glfw:$lwjglVersion")
implementation("org.lwjgl:lwjgl-opengl:$lwjglVersion")
runtimeOnly("org.lwjgl:lwjgl:$lwjglVersion:$lwjglNatives")
runtimeOnly("org.lwjgl:lwjgl-glfw:$lwjglVersion:$lwjglNatives")
runtimeOnly("org.lwjgl:lwjgl-opengl:$lwjglVersion:$lwjglNatives")
}
Skikoのバージョンと環境は次のリンクを確認して、最新のものを利用してください5。
https://github.com/JetBrains/skiko/releases
LWJGLのバージョンは次のリンクを確認してください。
https://mvnrepository.com/artifact/org.lwjgl/lwjgl
続いて、Main.ktを書き換えます。
import org.jetbrains.skia.*
import org.lwjgl.glfw.GLFW.*
import org.lwjgl.opengl.GL
import org.lwjgl.opengl.GL11
fun main() {
val width = 640
val height = 480
glfwInit()
glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE)
glfwWindowHint(GLFW_RESIZABLE, GLFW_TRUE)
val windowHandle = glfwCreateWindow(width, height, "Flume", 0, 0)
glfwMakeContextCurrent(windowHandle)
glfwSwapInterval(1)
glfwShowWindow(windowHandle)
GL.createCapabilities()
val context = DirectContext.makeGL()
val fbId = GL11.glGetInteger(0x8CA6)
val renderTarget =
BackendRenderTarget.makeGL(width, height, 0, 8, fbId, FramebufferFormat.GR_GL_RGBA8)
val surface = Surface.makeFromBackendRenderTarget(context,
renderTarget,
SurfaceOrigin.BOTTOM_LEFT,
SurfaceColorFormat.RGBA_8888,
ColorSpace.sRGB)!!
val canvas = surface.canvas
while (!glfwWindowShouldClose(windowHandle)) {
val paintGreen = Paint().apply { color = 0xFF8BC34A.toInt() }
canvas.drawCircle(100f, 100f, 40f, paintGreen)
val paintYellow = Paint().apply { color = 0xFFFFEB3B.toInt() }
canvas.drawCircle(200f, 100f, 40f, paintYellow)
val paintRed = Paint().apply { color = 0xFFF44336.toInt() }
canvas.drawCircle(300f, 100f, 40f, paintRed)
context.flush()
glfwSwapBuffers(windowHandle)
glfwPollEvents()
}
}
説明はあとに回すとして、これを実行すると、信号機のようなものがウィンドウ内に描かれた状態で開くことが確認できます。
これができれば第一関門は突破です!後はこのウィンドウに落書きする仕組みを作るだけです。
先へ進む前に少しコードを読みましょう。
main関数は、プログラムの実行開始時に呼ばれる最初の地点です。最初の十数行はGLFWでウィンドウを作るための設定を書いています。個々の関数の詳細はここでは重要ではないので、このように書けば画面サイズを指定してウィンドウを表示できる、と覚えておきましょう。
val width = 640
val height = 480
glfwInit()
glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE)
glfwWindowHint(GLFW_RESIZABLE, GLFW_TRUE)
val windowHandle = glfwCreateWindow(width, height, "Flume", 0, 0)
glfwMakeContextCurrent(windowHandle)
glfwSwapInterval(1)
glfwShowWindow(windowHandle)
GL.createCapabilities()
val context = DirectContext.makeGL()
val fbId = GL11.glGetInteger(0x8CA6)
val renderTarget =
BackendRenderTarget.makeGL(width, height, 0, 8, fbId, FramebufferFormat.GR_GL_RGBA8)
続いては、Skiaの設定を行う部分になります。Surface6というものを用意しています。
絵を描いたときにそれをピクセルとして管理してくれているようです。私達がこのSurfaceに絵を描く際には、下のCanvas7を利用します。Canvasは画面に描画するための簡単なインターフェスを提供してくれるクラスです。
val surface = Surface.makeFromBackendRenderTarget(context,
renderTarget,
SurfaceOrigin.BOTTOM_LEFT,
SurfaceColorFormat.RGBA_8888,
ColorSpace.sRGB)!!
val canvas = surface.canvas
設定の記述の後、メインループに入ります。glfwWindowShouldCloseでウィンドウが閉じたかどうかを判定しており、閉じない限りループを続けます。ループ内ではcanvasに描画し、SwapBufferすることで画面に表示されます。ここでは3色で3箇所に円を描く命令を記述しているため、信号機の模様が描かれます。
while (!glfwWindowShouldClose(windowHandle)) {
val paintGreen = Paint().apply { color = 0xFF8BC34A.toInt() }
canvas.drawCircle(100f, 100f, 40f, paintGreen)
val paintYellow = Paint().apply { color = 0xFFFFEB3B.toInt() }
canvas.drawCircle(200f, 100f, 40f, paintYellow)
val paintRed = Paint().apply { color = 0xFFF44336.toInt() }
canvas.drawCircle(300f, 100f, 40f, paintRed)
context.flush()
glfwSwapBuffers(windowHandle)
glfwPollEvents()
}
このようにして、ふたつのライブラリーだけを用いて画面の表示、そして画面に好きな模様を表示させることができました。
前章ではSkiaで円を描画しましたが、Skiaには他にもさまざまな機能があります。Flumeの実装で使うものを中心に紹介します。
円だけでなく、長方形、線など、いろいろと描くことができます。
while (!glfwWindowShouldClose(windowHandle)) {
val paintGreen = Paint().apply { color = 0xFF8BC34A.toInt() }
canvas.drawCircle(100f, 100f, 40f, paintGreen)
val paintYellow = Paint().apply { color = 0xFFFFEB3B.toInt() }
canvas.drawRect(Rect.makeXYWH(200f, 100f, 50f, 100f), paintYellow)
val paintRed = Paint().apply { color = 0xFFF44336.toInt() }
canvas.drawLine(300f, 100f, 400f, 200f, paintRed)
context.flush()
glfwSwapBuffers(windowHandle)
glfwPollEvents()
}
文字は少しややこしいですが、描画できます。
・ParagraphStyle: 文字のスタイルを指定
・FontCollection: 文字のフォントを指定
・ParagraphBuilder: Paragraphを用意するためのクラス
・Paragraph: 文字の描画情報をもつクラス
val style = ParagraphStyle().apply {
textStyle = TextStyle().apply {
color = 0xFFFF0000.toInt()
fontSize = 30f
}
}
val font = FontCollection().apply {
setDefaultFontManager(FontMgr.default)
}
val builder = ParagraphBuilder(
style, font
)
builder.addText("Flumeフルーム古め, Hello Flume!!")
val paragraph = builder.build()
while (!glfwWindowShouldClose(windowHandle)) {
paragraph.layout(300f)
paragraph.paint(canvas, 100f, 100f)
context.flush()
glfwSwapBuffers(windowHandle)
glfwPollEvents()
}
まず、ParagraphStyleとFontCollectionを用意して、ParagraphBuilderに渡します。ParagraphBuilder.addText()で表示したい文字を設定できます。その後、ParagraphBuilder.build()でParagraphオブジェクトを生成します。ここまでは描画の前に済ませておくことが可能です。実際に描画の段になったら、Paragraph.layout()で最大幅を指定して、大きさを計算します1。もし一行で収まらない場合は、標準で改行されます。
最後にcanvasを使ってParagraph.paint()に左上の座標を指定し、描画します。すると次のように、きれいに文字を描いてくれます。
canvas.transform()を使うと、描画位置をずらすことができます。
while (!glfwWindowShouldClose(windowHandle)) {
val paintRed = Paint().apply { color = 0xFFFF0000.toInt() }
val paintGreen = Paint().apply { color = 0xFF00FF00.toInt() }
canvas.drawRect(Rect.makeXYWH(100f,100f,100f,100f), paintRed)
canvas.translate(200f, 0f)
canvas.drawRect(Rect.makeXYWH(100f,100f,100f,100f), paintGreen)
canvas.translate(-200f, 0f)
context.flush()
glfwSwapBuffers(windowHandle)
glfwPollEvents()
}
赤の四角と緑の四角は同じ場所を指定していますが、緑を描く前にcanvasに対して移動を適用しているため、右に200だけずれた位置に四角が描かれます。
直接射影変換行列を適用することもできます。以下では30°回転させました。
while (!glfwWindowShouldClose(windowHandle)) {
val paintRed = Paint().apply { color = 0xFFFF0000.toInt() }
val paintGreen = Paint().apply { color = 0xFF00FF00.toInt() }
canvas.drawRect(Rect.makeXYWH(100f,100f,100f,100f), paintRed)
canvas.translate(200f, 0f)
val rot = (30 * PI / 180).toFloat()
canvas.concat(Matrix33(
cos(rot), -sin(rot), 0f,
sin(rot), cos(rot), 0f,
0f,0f,1f
))
canvas.drawRect(Rect.makeXYWH(100f,100f,100f,100f), paintGreen)
canvas.concat(Matrix33(
cos(-rot), -sin(-rot), 0f,
sin(-rot), cos(-rot), 0f,
0f,0f,1f
))
canvas.translate(-200f, 0f)
context.flush()
glfwSwapBuffers(windowHandle)
glfwPollEvents()
}
移動や変形などを適用した後は、もとの状態に戻さないと面倒なことになります。前の例では後で逆行列を掛けて元に戻していましたが、canvas.save()とcanvas.restore()を使うと、もっと単純に記述可能です。
while (!glfwWindowShouldClose(windowHandle)) {
val paintRed = Paint().apply { color = 0xFFFF0000.toInt() }
val paintGreen = Paint().apply { color = 0xFF00FF00.toInt() }
canvas.drawRect(Rect.makeXYWH(100f,100f,100f,100f), paintRed)
canvas.save() //現時点でのmatrixを保存
canvas.translate(200f, 0f)
val rot = (30 * PI / 180).toFloat()
canvas.concat(Matrix33(
cos(rot), -sin(rot), 0f,
sin(rot), cos(rot), 0f,
0f,0f,1f
))
canvas.drawRect(Rect.makeXYWH(100f,100f,100f,100f), paintGreen)
canvas.restore() // matrixを前に保存した状態に戻す
context.flush()
glfwSwapBuffers(windowHandle)
glfwPollEvents()
}
変換前にcanvas.save()を呼び出しておけば、canvas.restore()で変換をなかったことにできます。描画内容は削除されません。また、Stackのような挙動であるため、入れ子でsave/restoreしても直近の内容のみが復元されます。
一部の描画を半透明にしたい場合、canvas.saveLayer()を透明度を指定したPaintとセットで呼ぶことにより、後ろを透明にできます。これは変形のsave()と同様にrestore()を呼ぶことで、透明度の指定をやめられます。
while (!glfwWindowShouldClose(windowHandle)) {
canvas.clear(0xFFFFFFFF.toInt())
val paintRed = Paint().apply { color = 0xFFFF0000.toInt() }
val paintGreen = Paint().apply { color = 0xFF00FF00.toInt() }
canvas.drawRect(Rect.makeXYWH(100f,100f,100f,100f), paintRed)
val paintOpacity = Paint().apply { alpha = 50 }
canvas.saveLayer(Rect.makeXYWH(0f,0f, width.toFloat(), height.toFloat()), paintOpacity)
canvas.drawRect(Rect.makeXYWH(300f,100f,100f,100f), paintGreen)
canvas.restore()
context.flush()
glfwSwapBuffers(windowHandle)
glfwPollEvents()
}
重ねて書かれると透明度が機能しないのでループの最初にキャンバスをリセットする行を追加しました
Pathを使うと、自由な形で表示を切り取ることができます。切り取りたい空間を囲むPathを定義し、canvas.clipPath()を呼ぶと、その箇所だけ切り取って表示されます。これもrestore()すると削除されます。
while (!glfwWindowShouldClose(windowHandle)) {
canvas.clear(0xFFFFFFFF.toInt())
val paintRed = Paint().apply { color = 0xFFFF0000.toInt() }
val paintGreen = Paint().apply { color = 0xFF00FF00.toInt() }
canvas.drawRect(Rect.makeXYWH(100f,100f,100f,100f), paintRed)
canvas.save()
canvas.translate(300f, 100f)
val path = Path().apply {
lineTo(0f, 100f - 30f)
quadTo(100f / 4, 100f, 100f / 2, 100f)
quadTo(100f - 100f / 4, 100f, 100f, 100f - 30f)
lineTo(100f, 0f)
closePath()
}
canvas.clipPath(path, antiAlias = true)
canvas.drawRect(Rect.makeXYWH(0f,0f,100f,100f), paintGreen)
canvas.restore()
context.flush()
glfwSwapBuffers(windowHandle)
glfwPollEvents()
}
Skiaはドキュメントが絶望的に少ないので、API Referenceをじっくり読むか、誰かの書いた実装を読むなりしましょう。当然Flutter Engine内でも使われている4ので、そこから始めるのもありです。