Anki助手:解决孩子复习和家长辅导的痛点

背景

「Anki」是一款记忆软件,更确切的说它是一款智能安排我们复习知识点的工具。

Anki 对知识点的记忆上有非常大的帮助,基于遗忘曲线有针对性的适时重复,对于有一定自制力的成人来说,帮助非常的大,但对于缺乏自制力的孩子来说,使用体验上问题比较多:

  1. 为了防止小孩偷懒式的随意点击,需要家长来操作软件,对家长的投入很大
  2. 孩子需要长时间的使用手机/Pad等电子设备
  3. 需要家长了解软件的使用,同时无法远程辅导

AnkiDroid是一个开源软件,同时提供了对外的Api,适配做扩展插件,选择基于自定一个插件:Anki助手来解决上述痛点。

需求

解决上述痛点的方式:

  1. 打印复习Anki卡片:把当前需要复习和学习的问题打印出来,让小孩摆脱电子设备独立完成复习
  2. 家长检查:基于打印复习的结果,由家长把结果录入Anki软件,解决小孩偷懒式的随意点击
  3. 加强记忆:对于完全错误或部分错误的卡片需要加强记忆

具体的需求:

  1. 基于AnkiDroid的Api开发Anki助手
  2. Anki助手包括如下功能:
    1. 首页
    2. 打印复习卡片
    3. 家长检查
    4. 加强记忆

原型图

打印复习原型图:

家长检查原型图:

记忆加强原型图:

核心实现

AnkiDroid的Api

需要申请Ankidroid的权限:com.ichi2.anki.permission.READ_WRITE_DATABASE

查询当天所有到期复习的卡片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* 获取到期的卡片列表
*/
private fun getDueDeckList(): List<AnkiDeck> {
val deckList = arrayListOf<AnkiDeck>()

val decksCursor = App.context.contentResolver.query(
FlashCardsContract.Deck.CONTENT_ALL_URI,
null,
null,
null,
null
) ?: return deckList

decksCursor.use { it ->
if (it.moveToFirst()) {
do {
val deckId = it.getLong(it.getColumnIndex(FlashCardsContract.Deck.DECK_ID))
val deckName = it.getString(it.getColumnIndex(FlashCardsContract.Deck.DECK_NAME))
val deckCounts = it.getString(it.getColumnIndex(FlashCardsContract.Deck.DECK_COUNTS))

// 过滤掉 Default 类别
if (deckName == "Default") {
continue
}

val ankiDeck = AnkiDeck.fromString(deckId, deckName, deckCounts)
// 过滤掉无复习的类别
if(ankiDeck.deckDueCounts.getTotal() <= 0){
continue
}
deckList.add(ankiDeck)
} while (it.moveToNext())
}
}

return deckList
}

查询卡片的详情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private fun getDueCards(deckId: Long, numCards: Int): List<AnkiCard> {
val ankiCards = arrayListOf<AnkiCard>()

val cursor = App.context.contentResolver.query(
FlashCardsContract.ReviewInfo.CONTENT_URI,
null,
"limit=?, deckID=?",
arrayOf(numCards.toString(), deckId.toString()),
null
) ?: return ankiCards

cursor.use { it ->
if(it.moveToFirst()) {
do {
val noteId = it.getLong(it.getColumnIndex(FlashCardsContract.ReviewInfo.NOTE_ID))
val cardOrd = it.getInt(it.getColumnIndex(FlashCardsContract.ReviewInfo.CARD_ORD))
val buttonCount = it.getInt(it.getColumnIndex(FlashCardsContract.ReviewInfo.BUTTON_COUNT))

val nextReviewTimes = it.getString(it.getColumnIndex(FlashCardsContract.ReviewInfo.NEXT_REVIEW_TIMES))

// val mediaFiles = it.getString(it.getColumnIndex(FlashCardsContract.ReviewInfo.MEDIA_FILES))

val card = retrieveCard(noteId, cardOrd)
card.setReviewData(buttonCount, nextReviewTimes)
ankiCards.add(card)
} while (it.moveToNext())
}
}
return ankiCards
}

复习卡片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private fun answerCard(noteId: Long, cardOrd: Int, easy: Int): Int{
val values = ContentValues()
values.put(FlashCardsContract.ReviewInfo.NOTE_ID, noteId)
values.put(FlashCardsContract.ReviewInfo.CARD_ORD, cardOrd)
values.put(FlashCardsContract.ReviewInfo.EASE, easy)
values.put(FlashCardsContract.ReviewInfo.TIME_TAKEN, 5000)

return App.context.contentResolver.update(
FlashCardsContract.ReviewInfo.CONTENT_URI,
values,
null,
null
)
}

本地持久化

打印记录表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Entity(tableName = "print_table")
data class PrintEntity(
@PrimaryKey(autoGenerate = true)
val id: Int,
val name: String,
val time: Date?,
val deckEntitys: List<DeckEntity>,
var hasCheckAndSyncAnki: Boolean = false,

var strengthenMemoryCounts: Int = 0,
var hasStrengthenMemory: Boolean = false
)

data class DeckEntity(
val deckId: Long,
val name: String,
val total: Int,
var cards: List<CardEntity>
)

data class CardEntity(
val noteId: Long,
val cardOrd: Int,
val buttonCount: Int,
val nextReviewTimes: List<String>,

var answerEasy: Int = -1,
var hasStrengthenMemory: Boolean = false
)

对应的DAO:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Dao
interface PrintDao {
@Query("SELECT * FROM print_table ORDER BY time DESC")
fun getPrints(): List<PrintEntity>

@Query("SELECT * FROM print_table WHERE time BETWEEN :start AND :end ORDER BY time DESC")
fun getPrintsByDate(start: Long, end: Long): List<PrintEntity>

@Query("SELECT * FROM print_table WHERE id=:printId")
fun getPrintById(printId: Int): PrintEntity

@Insert
fun insertAll(vararg printEntitys: PrintEntity)

@Update
fun update(printEntity: PrintEntity)

@Delete
fun delete(printEntity: PrintEntity)

@Query("DELETE FROM print_table WHERE time < :date")
fun deleteBeforeDate(date: Long)
}

databinding + LiveData + Coroutine

框架为:DataBinding,LiveData,Coroutine

DataBinding做界面绑定,LiveData做数据监听,Coroutine做异步任务

源码

handsomeliuyang/anki_assist_app

参考

  1. android/architecture-samples
  2. ankidroid/Anki-Android
  3. AnkiDroid API
感谢您的阅读,本文由 刘阳 版权所有。如若转载,请注明出处:刘阳(https://handsomeliuyang.github.io/2021/07/26/%E7%BB%8F%E9%AA%8C%E6%80%BB%E7%BB%93-Anki%E5%8A%A9%E6%89%8B/
暗黑适配方案比较
Dart的const理解