前语

Jetpack Compose是一种声明式UI,它能够随着数据的改变而自动更新UI。它对于列表的改变和内容的更新十分敏感,也便利易用。因而我在想,用它来制作一个类似于Notion的块式文本修正器,是否也能够如此便利呢?

因而我在《小鹅事务所》的笔记模块中测验把旧的富文本修正器(EditText烘托 + HTML耐久化)替换成Compose块式修正器(TextField修正 + Markdown耐久化 + Text烘托)。

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

阅览该文章需求部分Room、Compose基础,因为篇幅内容并不会过多解释概念,因为代码冗繁,我只会在文章中放重点部分代码,若咱们感兴趣能够clone一份到本地,准备好的话咱们开始吧。

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

数据层

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页面

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

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,我后面会讲。

滑动删去

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

滑动删去能够运用谷歌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)
)

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

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

block格式化

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

关于Block的md格式化应该怎样做到呢?咱们需求考虑几个点:

  1. 获取当时聚集的block的value,前文也说过,能够经过InteractionSource获取。
  2. 若格式化该block需求上下文信息,则需求从block列表获取,例如图中格式化有序列表需求依据上一个数字决定需求运用哪个数字。
  3. 在格式化之后,当时cursor应该怎样改变?若直接运用字符串String进行格式化并给到TextField烘托会呈现一个问题,即cursor会因为增加或删去了文本导致前后挪。

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

因而咱们需求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,选中的区域也会同步移动。

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

驱动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中自带的。

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

因而我能够把新增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,里面的思路十分新颖。

参考

  • Rendering Markdown with Jetpack Compose

  • 多渠道Compose Markdown烘托库

  • Room官方教程

  • StateFlow

  • Effective Java第三版

  • Compose状况提高