山东大学创新项目实训(3)基于Kotlin的安卓app框架搭建与基础功能实现

本周工作:小组完成了项目基础框架的搭建,我负责单词学习(制定学习计划、查看实时学习进度、查看学习历史)、单词查询(全文索引、单词库模糊查询)、单词音标表等功能的实现。在此期间学习了kotlin经典四层框架的搭建、JetpackCompose安卓原生组件的基本语法和组件类型。

一、基础框架搭建

我们的项目使用kotlin编程语言,分为数据层、领域层、表示层和依赖注入四层结构。

1.数据层:app/data/

主要负责数据的获取和存储,包括本地数据库和API,其中:

local/: 负责本地数据存储。

dao/:  DAO为数据访问对象,定义了数据库操作方法。

entity/: 数据库实体,对应类。

AppDatabase.kt: 数据库的定义类。

repository/: 数据存取逻辑的实现。

model/: 定义数据模型,用于数据转换。

2.领域层:app/domain/

用于封装业务规则,通过Repository 接口访问数据,使得数据获取方式。

model/: 定义与 UI 交互的数据结构(模型)。

repository/: 定义接口,使数据层和业务逻辑层解耦。

usecase/: 用例,每个 UseCase 类执行一个特定的业务逻辑。

3.表示层:app/presentation/

负责 UI 逻辑,通过 ViewModelUseCase 获取数据,并绑定到 Jetpack Compose 组件。

component/: 可复用的Jetpack Compose 组件。

screen/: 各个功能的UI界面。

theme/: 应用主题。

navigation/: 应用导航栏。

viewmodel/: ViewModel 层。

4.依赖注入:di/

负责依赖注入配置,使用DaggerHilt进行管理,便于对依赖管理。

详情见VocabVerse背单词应用架构规划书-优快云博客

app/
├── data/
│   ├── local/          # 本地数据源
│   │   ├── dao/        # Room DAO接口
│   │   ├── entity/     # 数据库实体
│   │   └── AppDatabase.kt
│   ├── repository/     # 仓库实现
│   └── model/          # 数据模型
├── domain/
│   ├── model/          # 领域模型
│   ├── repository/     # 仓库接口
│   └── usecase/        # 业务用例
├── presentation/
│   ├── component/      # 可复用Compose组件
│   ├── screen/         # 各功能屏幕
│   ├── theme/          # 应用主题
│   ├── navigation/     # 导航配置
│   └── viewmodel/      # ViewModel
└── di/                 # 依赖注入配置

二、Jetpack Compose

我们的项目使用Jetpack Compose安卓原生组件实现UI布局。

使用原因:

声明式编程: Jetpack Compose使用声明式编程范式,代码简洁、好学。

Kotlin 原生支持: 完全使用 Kotlin 编写,与 Kotlin 语言可以无缝集成。

添加相关依赖:

implementation 'androidx.compose.runtime:runtime:1.4.1'
    implementation "androidx.compose.ui:ui:1.4.1"
    implementation "androidx.compose.ui:ui-tooling-preview:1.4.1"
    implementation "androidx.compose.material3:material3:1.1.0-beta02"
    implementation "androidx.compose.material3:material3-window-size-class:1.1.0-beta02"
    implementation "androidx.compose.animation:animation:1.4.1"

需要配置kotlin的相关插件:

plugins {
    id 'kotlin-kapt'
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    id 'org.jetbrains.kotlin.plugin.serialization'
    id 'com.google.dagger.hilt.android'

相关学习网站:

Android开发实战班 - 现代 UI 开发之 Jetpack Compose 基础-优快云博客

Android-Jetpack-Compose-最全上手指南 - 简书

从0上手Jetpack Compose,看这一篇就够了~不破不立,未来可期~ 不破不立,未来可期~ 不破不立,未来可期~ - 掘金

三、单词学习功能

模块架构:

将每个模块的实现分离为四个比部分:

PlanViewModel :负责数据获取和处理,并通过 PlanUiState 提供数据;

PlanScreen :根据 PlanUiState 渲染 UI,显示学习计划的详细内容;

StudyHistoryChart: UI 组件,通过 PlanUiState 中的历史数据进行渲染;

PlanUiState:定义不同状态(加载中、已存在、空),并通过状态驱动 UI 更新。

1.实现指定学习计划

指定学习计划应包括选择要学习的单词本、选择每日学习量、选择开始学习的日期等。

实现选择单词本:

NewPlanSectionTitle(
    iconId = R.drawable.ic_vocabulary_24dp,
    textId = R.string.new_plan_vocabulary_title,
)
LazyRow(
    contentPadding = PaddingValues(vertical = 4.dp),
    modifier = Modifier.fillMaxWidth().height(156.dp)
) {
    items(vocabularyList) {
        VocabularyItem(
            vocabulary = it,
            updateNewPlanVocabulary = updateNewPlanVocabulary
        )
        Spacer(modifier = Modifier.padding(end = 8.dp))
    }
}

这里设置了一个滚栏用来放单词本,调用updateNewPlanVocabular方法更新学习计划。

选择每日学习量:

NewPlanSectionTitle(
    iconId = R.drawable.ic_word_list_size_24dp,
    textId = R.string.new_plan_word_list_size_title,
)
LazyRow(
    contentPadding = PaddingValues(vertical = 2.dp),
    modifier = Modifier.fillMaxWidth()
) {
    items(studyAmountList) {
        OutlinedButton(
            onClick = { updateNewPlanWordListSize(it) },
            shape = MaterialTheme.shapes.medium,
            border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary),
            modifier = Modifier.padding(end = 16.dp, top = 4.dp, bottom = 4.dp)
        ) {
            Text(
                text = it.toString(),
                style = MaterialTheme.typography.bodyLarge,
                color = MaterialTheme.colorScheme.onBackground
            )
        }
    }
}

选择开始日期:

NewPlanSectionTitle(
    iconId = R.drawable.ic_date_24dp,
    textId = R.string.new_plan_start_date_title,
)
Row(
    modifier = Modifier
        .padding(vertical = 4.dp)
        .fillMaxWidth()
) {
    OutlinedButton(
        onClick = { updateNewPlanStartDate(getTodayDateTime()) },
        shape = MaterialTheme.shapes.medium,
        border = BorderStroke(1.dp, MaterialTheme.colorScheme.secondary),
    ) {
        Text(
            text = stringResource(R.string.new_plan_today_btn),
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.onBackground
        )
    }
    OutlinedButton(
        onClick = { updateNewPlanStartDate(getTomorrowDateTime()) },
        shape = MaterialTheme.shapes.medium,
        border = BorderStroke(1.dp, MaterialTheme.colorScheme.secondary),
    ) {
        Text(
            text = stringResource(R.string.new_plan_tomorrow_btn),
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.onBackground
        )
    }
}

2.实现学习计划、学习历史的可视化

构建学习计划进度条和饼状图:

chartDataList.forEachIndexed { index, section ->
            val sectionTextLayoutResult = sectionTextLayout(section.first)
            val sectionTextSize = sectionTextLayoutResult.size
            val sectionTextStartX = textIndent - (sectionTextSize.width / 2f)
            val sectionTextStartY = dp48Px * 1.2f - (sectionTextSize.height / 2f) + (index * lineGap)
            val sectionProgressStartX = dp16Px * 1.4f + sectionTextSize.width * 0.8f
            val sectionProgressStartY = dp48Px * 1.12f - (sectionTextSize.height / 2f) + (index * lineGap)
            val progressWidth = fullProgressWidth * (section.second / 100f)

            drawText(
                textLayoutResult = sectionTextLayoutResult,
                topLeft = Offset(sectionTextStartX, sectionTextStartY),
            )

            drawRoundRect(
                color = section.third,
                topLeft = Offset(sectionProgressStartX, sectionProgressStartY),
                size = Size(
                    width = progressWidth,
                    height = sectionTextSize.height * 1.3f
                )
            )

            val progressTextLayoutResult = sectionTextLayout("${section.second.toInt()} %")
            val progressTextSize = progressTextLayoutResult.size
            val progressTextStartX = textIndent + sectionTextSize.width + progressWidth + dp16Px - (progressTextSize.width / 2f)
            val progressTextStartY = dp48Px * 1.18f - (progressTextSize.height / 2f) + (index * lineGap)

            drawText(
                textLayoutResult = progressTextLayoutResult,
                topLeft = Offset(progressTextStartX, progressTextStartY),
            )
        }

        drawText(
            textLayoutResult = mottoTextLayout,
            topLeft = Offset(
                (this.size.width / 2f) - (mottoTextSize.width / 2f),
                heightPx - dp16Px * 1.1f - (mottoTextSize.height / 2f)
            ),
        )

drawCircle(
            color = totalColor,
            radius = radius,
            center = circleCenterOffset
        )

        drawArc(
            color = learnedColor,
            startAngle = -90f,
            sweepAngle = learnedAngle,
            useCenter = true,
            size = Size(radius * 2f, radius * 2f),
            topLeft = Offset(
                x = circleCenterOffset.x - radius,
                y = circleCenterOffset.y - radius
            )
        )

        drawText(
            textLayoutResult = dictionaryTextLayout,
            color = dictionaryColor,
            topLeft = Offset(
                this.size.width - dp48Px * 1.6f - (dictionaryTextSize.width / 2f),
                dp48Px * 1.4f - (dictionaryTextSize.height / 2f)
            ),
        )

构建学习历史柱状图:

val chartArea = Size(
            width = size.width - chartPadding * 2,
            height = heightPx - chartPaddingV * 2 - dp24
        )

        val maxValue = historyData.maxOfOrNull { it.second }?.toFloat() ?: 1f
        val barWidth = (chartArea.width / historyData.size) * 0.9f
        val barSpacing = (chartArea.width / historyData.size) * 0.15f

        historyData.forEachIndexed { index, (date, value) ->
            val barHeight = (value / maxValue) * chartArea.height
            val xPos = chartPadding + (barWidth + barSpacing) * index
            val yPos = heightPx - dp36 - barHeight

            drawRoundRect(
                color = barColor,
                topLeft = Offset(xPos, yPos),
                size = Size(barWidth, barHeight),
                cornerRadius = CornerRadius(4.dp.toPx())
            )

            val dateLayout = textMeasurer.measure(date, labelStyle)
            drawText(
                textLayoutResult = dateLayout,
                topLeft = Offset(
                    xPos + barWidth / 2 - dateLayout.size.width / 2,
                    heightPx - chartPaddingV + 8.dp.toPx()
                )
            )

            val valueLayout = textMeasurer.measure("$value", labelStyle)
            drawText(
                textLayoutResult = valueLayout,
                topLeft = Offset(
                    xPos + barWidth / 2 - valueLayout.size.width / 2,
                    yPos - valueLayout.size.height - 4.dp.toPx()
                )
            )

视觉效果:

数据处理部分:

表示层获取数据方法:

val simulatedHistory = generateSimulatedHistory()
            PlanUiState.Existed(
                studyPlan = plan,
                progressReport = progressRepository.getLatestLessonReport(
                    wordListSize = plan.wordListSize
                ),
                totalReport = progressRepository.getTotalReport(
                    vocabularySize = plan.vocabularySize
                ),
                studyHistory = simulatedHistory
            )

领域层设计访问数据的接口:

private val studyPlanDAO: StudyPlanDAO
) {

    fun getStudyPlanFlow() = studyPlanDAO.getStudyPlanFlow()
        .flowOn(Dispatchers.IO)

    suspend fun getStudyPlan() =
        studyPlanDAO.getStudyPlan()

四、单词查询功能

1.实现单词实时查询逻辑

采用智能列表布局:

FlowRow {
    suggestionList.forEach { suggestion ->
        OutlinedButton(onClick = { setWord(suggestion) }) {
            Text(text = suggestion)
        }
    }
}

自定义一个输入键盘,符合26键键盘的常规布局:

Spacer(modifier = Modifier.padding(vertical = 4.dp))
        LandingKeyboard(
            write = write,
            remove = clearAlphabet,
            modifier = Modifier
                .padding(bottom = 32.dp)
                .weight(4.2f)
                .fillMaxWidth()
        )

数据加载逻辑:

sealed interface SearchUiState {
    object Loading : SearchUiState
    data class Default(
        val spelling: String = "请输入要搜索的单词",
        val suggestionList: List<String> = emptyList(),
        val searchHistory: Flow<PagingData<SearchHistoryItem>>,
        val dialog: Dialog = Dialog.None
    ) : SearchUiState {
        enum class Dialog { History, None }
    }
}

搜索逻辑:

fun write(alphabet: String) {
    viewModelScope.launch {
        val suggestions = wordRepository.getSearchSuggestions(updated)
        updateState(suggestions)
    }
}

基于当前输入实时获取搜索结果,异步处理。符合APP用户的一般搜索习惯。

界面效果:

2.数据层实现模糊搜索功能

模糊搜索SQL语句:

@Query(
        """
        SELECT spelling FROM word_list WHERE spelling LIKE :spelling 
        ORDER BY LENGTH(spelling) LIMIT $MAX_SUGGESTIONS_NUM
        """
    )

使用LIKE操作符来模糊匹配,然后按长度排序,取前MAX_SUGGESTIONS_NUM个结果。

建立索引优化搜索性能:

CREATE INDEX idx_word_spelling ON word_list(spelling)

五、音标功能

音标表UI布局:

每行布局:

Row(
        verticalAlignment = Alignment.CenterVertically,
        modifier = modifier
            .fillMaxWidth()
            .padding(vertical = 2.dp)
            .background(
                Color(0xFFE3E3E3).copy(alpha = 0.4f),
                shape = RoundedCornerShape(4.dp)
            )
            .padding(horizontal = 1.dp)
    ) 

类卡片式布局,用略深的底色区分每行。

表格布局:

LazyColumn(
        modifier = Modifier.fillMaxSize()
    ) {
        items(ipaList) {
            IpaListItem(
                ipa = it,
                playPron = playPron,
                modifier = Modifier
                    .padding(16.dp)
                    .fillMaxWidth()
                    .height(IntrinsicSize.Min)
            )
        }
    }

音标数据获取:

 @Query("SELECT * FROM ipa")
    suspend fun getIpaList(): List<Ipa>

分辅音/原音

UI效果:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

dt23333

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值