【Android探索】用Compose做一个Markdown文本块编辑器

3,121 阅读11分钟

前言

Jetpack Compose是一种声明式UI,它可以随着数据的变化而自动更新UI。它对于列表的变化和内容的更新非常敏感,也方便易用。因此我在想,用它来制作一个类似于Notion的块式文本编辑器,是否也可以如此方便呢?

因此我在《小鹅事务所》的笔记模块中尝试把旧的富文本编辑器(EditText渲染 + HTML持久化)替换成Compose块式编辑器(TextField编辑 + Markdown持久化 + Text渲染)。

20230607_174029.gif

阅读该文章需要部分Room、Compose基础,由于篇幅内容并不会过多解释概念,由于代码冗杂,我只会在文章中放重点部分代码,若大家感兴趣可以clone一份到本地,准备好的话我们开始吧。

Frame 26085527.png

数据层

Entities

本次数据存储将使用SQLite完成,我们可以使用Room作为数据库框架。先定义两个Entity类创建两个表,分别是NoteNoteContentBlock

@Entity(tableName = TABLE_NOTE)
data class Note(
    @PrimaryKey(autoGenerate = true)
    val id: Long? = null,
    val title: String = "",
    val time: Date = Date()
)

Note entity存放自增的主键id、标题和最后修改时间。

@Entity(tableName = TABLE_NOTE_CONTENT_BLOCK)
data class NoteContentBlock(
    @PrimaryKey(autoGenerate = true)
    val id: Long? = null,
    @ColumnInfo("note_id")
    val noteId: Long? = null,
    val index: Int = 0,
    val content: String = ""
)

NoteContentBlock entity存放id、关联Note的note_id、index和内容。index为该block在列表中的索引。

Dao

定义完数据类之后,Room需要一个Dao来定义和数据库的交互逻辑。

在编写函数之前,我们注意到,每一个Note可能关联多个NoteContentBlock,而他们的关联点为Note中的idNoteContentBlocknote_id。因此可以写出以下SQL Query语句。

@Dao
interface NoteDao {
    // ...
    @Transaction
    @Query(
        "SELECT * FROM $TABLE_NOTE " +
                "JOIN $TABLE_NOTE_CONTENT_BLOCK " +
                "ON $TABLE_NOTE.id = $TABLE_NOTE_CONTENT_BLOCK.note_id " +
                "WHERE $TABLE_NOTE.id = :noteId " +
                "ORDER BY $TABLE_NOTE_CONTENT_BLOCK.`index` ASC"
    )
    fun getNoteWithContentMapFlow(noteId: Long): Flow<Map<Note, List<NoteContentBlock>>>
}

这个方法需要执行两次查询,因此需要添加 @Transaction 注解,以确保整个操作以原子方式执行。尽管我们知道只会找到一个Note和一个NoteContentBlock列表,由于SQLite本身不知道会找到多少对NoteNoteContentBlock列表,因此只能返回一个Map。而此处我使用了Flow 来监听数据的变化。

Database

创建一个数据库类

@Database(
    entities = [Note::class, NoteContentBlock::class],
    version = 1,
    exportSchema = false
)
@TypeConverters(CommonTypeConverters::class)
abstract class NoteDatabase : RoomDatabase() {
    abstract fun noteDao(): NoteDao
}

Repository和UseCase

根据谷歌目前的最佳架构实践,创建RepositoryUseCase类作为Domain层。以下展示部分代码:

// Repository
class NoteRepository(context: Context) {

    private val database: NoteDatabase = Room.databaseBuilder(
        context, NoteDatabase::class.java, TABLE_NOTE
    ).addMigrations(...).build()

    private val noteDao = database.noteDao()

    suspend fun insertNote(note: Note) = noteDao.insertNote(note)

    // ...
    
    suspend fun insertNoteContentBlock(noteContentBlock: NoteContentBlock): Long {
        return noteDao.insertNoteContentBlock(noteContentBlock)
    }

}

// UseCase
class UpdateNoteUseCase @Inject constructor(
    private val repository: NoteRepository
) {
    suspend operator fun invoke(note: Note) {
        return repository.updateNote(note)
    }
}

UI层

由于Compose是声明式UI,可以使用@Preview注解加上假数据预览,非常方便。因此我们可以先看UI层,可以先定义需要什么State,再去ViewModel层为UI层准备State

Note页面

Frame_26085525.png

NoteScreen可以分为三部分,分别为TopBar、BottomBar和App Content。我们先从顶层往底层拆解App Content。

@Composable
fun NoteContent(
    modifier: Modifier = Modifier,
    state: NoteContentState,
    blockColumnState: LazyListState
) {
    if (state.isPreview) {
        MarkdownContent(
            modifier = Modifier.fillMaxSize(),
            state = state
        )
    } else {
        NoteEditContent(
            modifier = Modifier.fillMaxWidth(),
            state = state,
            blockColumnState = blockColumnState
        )
    }
}

App Content分为两个模式,Markdown预览模式编辑模式,我们先看编辑模式的UI:

编辑模式的页面由两个部分组成,分别为标题的编辑TextField和内容的编辑列表,我们可以使用LazyColumn来完成。

这个页面UI就这么简单,由三个部分组成,分别为标题、内容、添加block的按钮。

@Composable
fun NoteEditContent(
    modifier: Modifier = Modifier,
    state: NoteContentState,
    blockColumnState: LazyListState
) {
    LazyColumn(
        modifier = modifier,
        state = blockColumnState
    ) {
        item {
            // 标题
            TextField( /* ... */ )
        }
        
        items(
            count = state.content.size,
            key = { state.content[it].id ?: 0 }
        ) { index ->
            // 内容
            val block = state.content[index]
            NoteContentBlockItem(
                modifier = Modifier.animateItemPlacement(), // item动画
                value = state.textFieldValues[block.id]!!,
                /* ... */
            )
        }
        
        item {
            // 添加新的block的按钮
            Card( /* ... */ )
        }
    }
}

由于LazyColumnitem默认是没类似于RecyclerViewitem动画的,可以使用animateItemPlacement把动画加上。

文本编辑块

重要的UI状态提升

我们重点看NoteContentBlockItem,设计该UI需要考虑两个非常重要的功能:

  • 在添加Item的时候需要把焦点给到新的Item。然而添加数据的逻辑一般不在UI层。
  • 在点击BottomBar中的按钮的时候,需要获取到当前聚焦的Item,并format其中的内容。然而两个Compose函数之间不能获取到对方的数据。

那么该怎么解决这两个问题呢?我们大家在学习Compose的时候经常看到一个名词:状态提升。只要我们把所需要的状态提升到最低的共同的父级就,不同的组件就可以通过共享这个状态来实现它们之间的通信,这样问题就解决了。

@Composable
fun NoteContentBlockItem(
    modifier: Modifier = Modifier,
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit,
    onBlockDelete: () -> Unit,
    interactionSource: MutableInteractionSource,
    focusRequester: FocusRequester
) {
    /* ... */

    // 封装了 TextField 样式
    NoteContentBlockTextField(
        modifier = Modifier.weight(1F),
        value = value,
        onValueChange = onValueChange,
        interactionSource = interactionSource,
        focusRequester = focusRequester
   )
}
  1. 由于在添加Item的时候把焦点给到新的Item,因此需要把FocusRequester提升。
  2. 由于需要获取当前聚焦的Item,因此我需要把InteractionSource提升,使用InteractionSource可以知道该Item的交互状态,也就是说外部已经可以获取到当前聚焦的Item是哪一个了。
  3. 考虑到方便Preview UI,在传入参数的时候我并没有将Block数据结构传进来,而是只传了文本内容TextFieldValue,至于为什么用TextFieldValue而不用String,我后面会讲。

滑动删除

滑动删除.gif

滑动删除可以使用谷歌Material3提供的Compose函数:SwipeToDismiss

  • background参数传入的是滑动的时候底层的内容
  • dismissContent参数传入的是顶层的内容
  • directions 传入的是需要使用滑动类型,支持前后滑动。而我只需要向后滑动
val dismissState = rememberDismissState()
SwipeToDismiss(
    modifier = modifier,
    state = dismissState,
    background = {
        /* 背景,用来展示Delete Icon */
    },
    dismissContent = {
        /* 文本编辑块 */
    },
    directions = remember { setOf(DismissDirection.StartToEnd) }
)
LaunchedEffect(dismissState) {
    snapshotFlow {
        dismissState.currentValue
    }.collect { dismissValue ->
        if (dismissValue == DismissValue.DismissedToEnd) {
            onBlockDelete()
        }
    }
}

而Delete Icon变大的动画怎么做呢?可以使用animateFloatAsState动画API,如下:

Box(
    modifier = Modifier
        .fillMaxSize()
        .background(MaterialTheme.colorScheme.tertiaryContainer),
    contentAlignment = Alignment.CenterStart
) {
    val targetValue = dismissState.targetValue
    val currentValue = dismissState.currentValue
    val alpha by animateFloatAsState(
        targetValue = if (
            targetValue == DismissValue.DismissedToEnd
            || currentValue == DismissValue.DismissedToEnd
        ) 1F else 0.5F
    )
    val scale by animateFloatAsState(
        targetValue = if (
            targetValue == DismissValue.DismissedToEnd
            || currentValue == DismissValue.DismissedToEnd
        ) 1F else 0.72F
    )
    Icon(
        modifier = Modifier
            .padding(start = 8.dp)
            .alpha(alpha)
            .scale(scale),
        imageVector = Icons.Rounded.Delete,
        contentDescription = "Delete"
    )
}

Markdown渲染

关于Compose如何渲染Markdown文本可以查看Rendering Markdown with Jetpack Compose,我使用Mike Penz根据这个思想编写的多平台Compose Markdown渲染库来进行MD渲染。

在渲染之前,需要把列表中的所有block全部合在一起,形成一个完整的Markdown文本,于是我使用了produceState函数,先给一个空字符串初始值,然后Markdown文本再放到子线程进行加工。

val fullContent by produceState(initialValue = "", key1 = state.content) {
    value = withContext(Dispatchers.Default) {
        buildString {
            if (state.title.isNotBlank()) {
                append("# ${state.title}\n\n")
            }
            append(state.content.joinToString("\n\n") { it.content })
        }
    }
}

由于一个Markdown文本是很长的,并且需要滑动,因此需要使用verticalScrollModifier。

val scrollState = rememberScrollState()
Markdown(
    content = fullContent,
    modifier = modifier
        .fillMaxSize()
        .verticalScroll(scrollState)
        .padding(16.dp)
)

渲染出来的效果如下所示,感觉还不错:

Untitled.png

block格式化

20230607_152047.gif

关于Block的md格式化应该怎么做到呢?我们需要考虑几个点:

  1. 获取当前聚焦的block的value,前文也说过,可以通过InteractionSource获取。
  2. 若格式化该block需要上下文信息,则需要从block列表获取,例如图中格式化有序列表需要根据上一个数字决定需要使用哪个数字。
  3. 在格式化之后,当前cursor应该如何变化?若直接使用字符串String进行格式化并给到TextField渲染会出现一个问题,即cursor会由于添加或删除了文本导致前后挪。

20230607_152816.gif

因此我们需要TextFieldValue,并修改其中的selection字段来实现复位。

获取当前聚焦的block进行format

获取焦点block

我们在实例MutableInteractionSource的时候可以对其中的interactions进行监听,示例代码如下:

val block: NoteContentBlock
val interactionSource = _interactionCache[block.id] ?: MutableInteractionSource()
    .also { ins ->
        ins.interactions.onEach {
            if (it is FocusInteraction.Focus) {
                changeFocusingBlockId(block.id)
            } else if (it is FocusInteraction.Unfocus) {
                // 判断本地的焦点是否为当前block,如果是的话则清除。
                if (this@NoteContentStateFlowDelegate.focusingBlockId.value == block.id) {
                    changeFocusingBlockId(null)
                }
            }
        }.launchIn(coroutineScope)
    }

若监听到某一个Block获取到焦点,则可以将焦点id存到本地,供format的时候进行读取。若某个Block失去焦点了,则可以判断当前焦点id是否为失去焦点的block的id,如果是的话则清除焦点,如果不是的话就不做操作。

这里考虑到一点:监听到新的焦点和旧的焦点丢失的时序是不确定的,因此需要加一个判断操作。

format焦点block

由于本地已经存了一份焦点id,因此我们在执行format文本的时候可以先去本地获取:

val focusingContentBlock = _focusingBlockId.value?.let { focusingBlockId ->
    nwc.content.findLast { it.id == focusingBlockId }
} ?: return@launchWithWritingMutex

由于存的是焦点id,因此需要去最新的NoteContentBlock列表中去找最新的block。那么为什么不直接存焦点block数据结构呢?

由于实例化MutableInteractionSource监听焦点的时候,NoteContentBlock刚刚被创建,并且在监听到取焦点的时候获取到的刚刚被创建的block是旧的。会有数据过时的问题,而id是恒不变的,因此存下为焦点id。

需要注意的是此处使用了findLast来查找而不是find的原因是:我们通常编辑列表下方文本比较多,因此findLast可以节省部分时间。

若需要获取上下文信息,则可以根据找到的block的索引信息去列表去查找,例如我想找到上一个block的有序列表数字我可以这么干:

val realType =
    if (type is FormatType.List.Ordered && focusingContentBlock.index > 0) {
        val previewNum =
            nwc.content[focusingContentBlock.index - 1].content.orderListNum
        FormatType.List.Ordered(previewNum + 1)
    } else type

然后就可以对TextFieldValue进行format操作了

val content = _textFieldValueCache.value[focusingContentBlock.id]?.format(realType)

如何format?

代码中的format是一个扩展函数,我目前定义了两个FormatType大类:

sealed class FormatType(val value: String) {

    sealed class Header(value: String) : FormatType(value) {
        object H1 : Header("# ")
        object H2 : Header("## ")
        // ** 省略
    }

    sealed class List(value: String) : FormatType(value) {
        object Unordered : List("- ")
        data class Ordered(private val num: Int) : List("$num. ")
    }

}

fun TextFieldValue.format(
    type: FormatType
): TextFieldValue {
    return when (type) {
        is FormatType.Header -> { /*... */}
        is FormatType.List -> {
            formatList(type)
        }
    }
}

我们简单看一下formatList如何操作:

private fun TextFieldValue.formatList(
    listType: FormatType.List
): TextFieldValue {
    return if (this.text.startsWith(listType.value)) {
        this.copy(
            text = this.text.substring(listType.value.length),
            selection = TextRange(
                this.selection.start - listType.value.length,
                this.selection.end - listType.value.length
            )
        )
    } else {
        // 省略部分代码 ...
        this.copy(
            text = listType.value + this.text,
            selection = TextRange(
                this.selection.start + listType.value.length,
                this.selection.end + listType.value.length
            )
        )
    }
}

需要注意的是:在执行完format之后,需要同步修改selection,否则会出现上面动图的Cursor不跟手的现象。修改完selection之后,在选中部分文本进行format,选中的区域也会同步移动。

Untitled 1.gif

驱动UI变化

由于所有复杂逻辑都是在ViewModel来进行的,那么该如何驱动UI变化呢?其实非常简单,有且仅有两种方式:UI State变更、Event发送。

UI State变更

众所周知,在Compose中维护UI状态并驱动UI变更大多数使用的是如下State

val state = remember { mutableStateOf("") }

ViewModel中也可以使用mutableStateOf来维护UI状态,在逻辑简单的情况下这么做也是最佳实践。但是在这次的逻辑比较复杂,如果使用到Flow会极大提高编码效率。在ViewModel中维护StateFlow并且在Compose中转换成所需的State:

class NoteViewModel(/*...*/): ViewModel() {
    val noteRouteState: StateFlow<NoteRouteState>
}

@Composable
fun NoteRoute(
    modifier: Modifier = Modifier,
    onBack: () -> Unit
) {
    val viewModel = hiltViewModel<NoteViewModel>()
    // 两种可选方式
    val noteRouteState by viewModel.noteRouteState.collectAsState()
    val noteRouteState by viewModel.noteRouteState.collectAsStateWithLifecycle()
}

有两个API可以将Flow转换成State,前者在所有时间都会监听并刷新UI。而后者仅在指定Lifecycle.State以上的状态才能监听并刷新UI,默认为START。

StateFlow

使用StateFlow需要注意的一点是:StateFlow会使用equals判断新进来的value是否相等,若相等的话,UI就不监听这个value,防止不必要的UI刷新。

因此在使用的时候我有以下建议:

  • 若数据类型为class,建议重写equals
  • 若数据类型为data class,在更新value的时候建议使用copy来重建新的实例。
  • 若数据类型为集合,建议使用不可变的集合,并且在更新的时候新建一个集合实例。

举一个我在代码中的例子:

private fun format(
    type: FormatType
) {
    ...
    val content = _textFieldValueCache.value[focusingContentBlock.id]
        ?.format(realType)
        ?.also {
            // 新建一个新的 Map,并修改其中的内容,赋值给 StateFlow 的 value
            _textFieldValueCache.value = _textFieldValueCache.value.toMutableMap()
                .apply { put(focusingContentBlock.id, it) }
        } ?: return@launchWithWritingMutex
    // 使用 copy 函数新建一个 NoteContentBlock 实例,并修改里面的 content
    val newBlock = focusingContentBlock.copy(content = content.text)
    ...    
}

牢记这一点很重要。

可能有人会说:重复创建很多实例了,会造成很多没必要的性能消耗。

这里我引用Effective Java中的一句话,关于这一点的讨论可以翻阅原文:

“当你应该创建新对象的时候,请不要重用现有的对象”,在提倡使用保护性拷贝的时候,因重用对象而付出的代价要远远大于因创建重复对象而付出的代价。

Event发送

有某些时候,逻辑层不仅仅需要为UI层提供UI State,还需要发送事件。举个例子,在新增block的时候,我想把焦点转移到新的block。

但是,ViewModel的viewModelScope是不允许调用LazyListStatescrollTo函数的,缺少MonotonicFrameClock上下文,而这个上下文是Compose的CoroutineScope中自带的。

IllegalStateException.png

因此我可以把新增block的事件发送到UI层,UI层监听该事件去滑动到新增的block并申请焦点,代码如下。

LaunchedEffect(viewModel.noteScreenEvent) {
    viewModel.noteScreenEvent.collectLatest { event ->
        when (event) {
            is NoteScreenEvent.AddNoteBlock -> {
                val noteContentState = (noteRouteState as? NoteRouteState.State)
                    ?.state?.contentState ?: return@collectLatest
                // 定位新增的 Block
                val blockIndex = noteContentState.content
                    .indexOf(event.noteContentBlock)
                if (blockIndex == noteContentState.content.lastIndex) {
                    // 最后一个 Block,滚动到底部,展示新增按钮
                    blockColumnState.animateScrollToItem(
                        blockColumnState.layoutInfo.totalItemsCount - 1
                    )
                } else if (blockIndex != -1) {
                    // 标题占了一个位置,所以要 +1
                    blockColumnState.animateScrollToItem(blockIndex + 1)
                } else {
                    // 等待 focusRequester 被添加到 block component 中
                    delay(66)
                }
                // 为新增的 Block 申请焦点
                var tryTime = 0
                do {
                    tryTime++
                    val result = runCatching {
                        noteContentState.focusRequesters[event.noteContentBlock.id]
                            ?.requestFocus()
                    }.onFailure {
                        awaitFrame()
                    }
                } while (result.isFailure && tryTime < 5)
            }
        }
    }
}

FocusRequester需要被添加到Compose组件中才可以申请焦点,否则会报错,因此做了重试操作。由于LazyListStatescrollTo函数一般在Compose组件被渲染之后才执行完毕,因此申请焦点的时候一般不会报错,保险起见做了一个重试操作。

架构优化

Kotlin委托

当我在使用ViewModel的时候,我发现了一个问题,当我把所有逻辑都写在了ViewModel,由于我需要BottomBarState,ContentState等等,整个ViewModel变得臃肿不堪难以维护。

针对这个问题,我结合Kotlin委托属性的功能,把StateFlow和它所附带的逻辑整个委托出去,所有逻辑封装在委托类中,而我只需要取这个StateFlow就好了,我不关注也无法获取委托类中的逻辑。

于是我写出了如下代码:

class NoteViewModel( /*...*/) : ViewModel() {

    private val _noteScreenEvent = MutableSharedFlow<NoteScreenEvent>()
    val noteScreenEvent = _noteScreenEvent.asSharedFlow()

    val noteRouteState: StateFlow<NoteRouteState> by NoteRouteStateFlowDelegate( /*...*/ )

    // ...
}

class NoteRouteStateFlowDelegate(
    noteIdFlow: StateFlow<Long>,
    /* 省略参数 */
) : ReadOnlyProperty<ViewModel, StateFlow<NoteRouteState>> {

    // 将 NoteContentState 和它的逻辑 委托出去
    private val noteContentState: StateFlow<NoteContentState?> 
            by NoteContentStateFlowDelegate(...)

    // 将 NoteBottomBarState 和它的逻辑 委托出去
    private val noteBottomBarState: StateFlow<NoteBottomBarState> 
            by NoteBottomStateFlowDelegate(...)

    // 取两者合成一个
    private val noteRouteState = combine(
        noteContentState.filterNotNull(), noteBottomBarState
    ) { noteContentState, noteBottomBarState ->
        NoteRouteState.State(
            NoteScreenState(
                contentState = noteContentState,
                bottomBarState = noteBottomBarState
            )
        )
    }.stateIn(
        scope = coroutineScope, SharingStarted.WhileSubscribed(5000L), NoteRouteState.Loading
    )

    / * 省略逻辑 */

    override fun getValue(
        thisRef: Any?,
        property: KProperty<*>
    ): StateFlow<NoteContentState?> {
        return noteContentStateFlow
    }
}

class NoteContentStateFlowDelegate(...) : ReadOnlyProperty<Any?, StateFlow<NoteContentState?>> { ... }
class NoteBottomStateFlowDelegate(...) : ReadOnlyProperty<Any?, StateFlow<NoteBottomBarState>> { ... }

ViewModel层由多个委托实现逻辑,并且这些委托封装了大量逻辑,仅暴露了一个StateFlow的value,除此之外无法获取委托中的其他信息,不仅解耦了逻辑,还极大增加了封装性。关于架构这一块我后续可能会聊聊。

总结

絮絮叨叨地把《小鹅事务所》中的Markdown编辑器模块的思路和坑点给讲了一遍,我也不知道讲明白没有,我不喜欢展示太多代码,建议clone一份代码到本地看一下。该模块还有很多不完善之处,仅做抛砖引玉作用。

使用Compose实现文本块编辑器还算比较轻松,心智负担非常小,编写不同模块的时候无需考虑其他模块的事情。

  • 在我编写UI层代码的时候,我只需考虑UI界面该怎么写,需要什么数据全部放到入参,在预览的时候非常方便,后续开发的时候按需传参即可。
  • 在编写ViewModel层的时候,只需考虑怎么驱动数据逻辑,并将数据转换成流暴露给UI。

关于Markdown实际渲染我着墨不多,更多的是如何编写文本块编辑器,但是关于Markdown渲染的思想非常建议看一下Rendering Markdown with Jetpack Compose,里面的思路非常新颖。

参考