1. Giới thiệu

1.1. Flashcard

Flashcard hoặc Flash Card là loại thẻ mang thông tin (từ, số hoặc cả hai), được sử dụng cho việc học bài trên lớp hoặc trong nghiên cứu cá nhân. người dùng sẽ viết một câu hỏi ở mặt trước thẻ và một câu trả lời ở trang sau. Người ta thường dùng flashcard học từ vựng tiếng Anh rất hiệu quả. Ngoài ra có thể dùng flashcard để học ngày tháng năm lịch sử, công thức hoặc bất kỳ vấn đề gì có thể được học thông qua định dạng một câu hỏi và câu trả lời. Flashcard được sử dụng rộng rãi như một cách rèn luyện để hỗ trợ ghi nhớ bằng cách lặp đi lặp lại cách nhau.

Flashcard là một công cụ ôn tập rất hiệu quả. Theo khoa học nghiên cứu, với một lượng kiến thức cần nhớ, thì sau 1 ngày tiếp thu, người học chỉ còn nhớ 35.7% lượng kiến thức và sau 1 tháng, lượng kiến thức chỉ còn khoảng 21% trong não bộ. Vì thế, việc ôn tập lại kiến thức đóng vai trò rất quan trọng trong quá trình ghi nhớ.

pasted-image.jpg

Không dừng lại ở tính hiệu quả cao, flashcard còn là một phương pháp học năng động. Với thiết kế nhỏ gọn, người học có thể đem flashcard theo bên mình và sử dụng mọi lúc mọi nơi.

(theo Wiki)

Ngày nay với sự phát triển của smart phone, có rất nhiều chương trình flashcard, chủ yếu dùng để học ngoại ngữ. Phần lớn các chương trình để nâng cao tính hiệu quả của việc học đều dùng một thuật toán gọi là Spaced Repetition.

1.2. Spaced Repetition

Spaced Repetition (SR) là một kỹ năng học tập dựa trên việc tính toán các khoảng thời gian giữa các lần ôn lại bài học tuỳ theo độ khó của bài học và trí nhớ của người học.

SR thích hợp trong nhiều hoàn cảnh, đặc biệt trong trường hợp người học cần phải ghi nhớ một lượng lớn nội dung, ví dụ như học từ mới ngoại ngữ.

Hình vẽ dưới đây mô tả quá trình học flashcard dựa trên SR: các thẻ trả lời đúng được đưa lên các hộp tiếp theo (hộp có số thứ tự càng lớn thì càng ít lặp lại thường xuyên) và các thẻ trả lời sai bị trả về hộp đầu tiên (lặp lại thường xuyên hơn)

SR.jpg

1.3. Thuật toán SuperMemo

SuperMemo (Super Memory - SM) là một phương pháp học tập và phần mềm được phát triển bởi SuperMemo World và SuperMemo R&D, tác giả là Piotr Woźniak người Phần Lan từ năm 1985 tới nay. Thuật toán này dựa trên nghiên cứu về trí nhớ dài hạn và ứng dụng phương pháp SR được đề xuất bởi một số nhà tâm lý học vào đầu những năm 1930.

Các thuật toán SM gồm:

  • SM-0: Thuật toán gốc (không dựa trên máy tính)
  • SM-2: Dựa trên máy tính (1987), các phiên bản tiếp theo tối ưu hoá ưu điểm của thuật toán này.
  • SM-15: hiện tại đang được SuperMemo sử dụng

Trong đó, SM-2 được sử dụng rộng rãi trong nhiều ứng dụng miễn phí phổ biến như Anki, Mnemosyne, Emacs Org-mode's Org-drill…

1.4. Thuật toán SM-2

Công thức:

I(1):=1
I(2):=6
for n>2 I(n):=I(n-1)*EF

Trong đó:

  • I(n) (interval): trả về khoảng thời gian đối tượng sẽ lặp lại (tính bằng ngày) sau n lần thử
  • EF (E-Factor): hệ số độ dễ, phản ánh độ dễ hay khó của đối tượng trong việc ghi nhớ.

EF: biến thiên từ 1.1 (khó nhất) và 2.5 (dễ nhất), mặc định khi một đối tượng được lưu vào database sẽ có Ef = 2.5. Trong quá trình học, giá trị này sẽ tăng hoặc giảm tuỳ thuộc vào sự ghi nhớ của người học.

Giá trị EF mới được tính toán dựa trên chất lượng câu trả lời của người học, lựa chọn 1 trong 6 tuỳ chọn:

  • 5 - Hoàn hảo
  • 4 - Trả lời chính xác nhưng còn phải đắn đo
  • 3 - Trả lời chính xác nhưng gặp nhiều khó khăn
  • 2 - Trả lời không chính xác, đáp án đúng dễ dàng nhớ ra
  • 1 - Trả lời sai, nhớ được đáp án
  • 0 - Hoàn toàn không nhớ

Công thức:

EF’:=EF+(0.1-(5-q)*(0.08+(5-q)*0.02))

Trong đó:

  • EF’ - giá trị mới của E-Factor
  • EF - giá trị cũ của E-Factor
  • q - giá trị của câu trả lời (0~5)

Khi EF < 1.3, gán EF = 1.3 (Đối tượng có EF < 1.3 sẽ lặp lại thường xuyên gây khó chịu)

Khi giá trị câu trả lời nhỏ hơn 3, ta tiến hành lập lại đối tượng từ đầu mà không thay đổi EF (VD: I(1), I(2) coi như đối tượng được học mới)

Sau mỗi lần học của 1 ngày, lặp lại tất cả các đối tượng có giá trị trả lời (q) nhỏ hơn 4. Tiếp tục lặp lại cho đến khi toàn bộ các đối tượng có giá trị trả lời ít nhất là 4.

Trong khuôn khổ bài viết này, tôi sẽ hướng dẫn các bạn tạo 1 ứng dụng iOS Flashcard áp dụng thuật toán SM.

2. Xây dựng ứng dụng MGFlashcardDemo

2.1. Tạo ứng dụng

Mở Xcode, tạo mới 1 ứng dụng iOS theo template Tabbed Application, ngôn ngữ Swift như hình dưới:

Screen Shot 2015-06-26 at 8.38.23 AM.png

2.2. Xây dựng class áp dụng thuật toán SM-2

2.2.1. SchedulingAlgorithm

SchedulingAlgorithm là 1 abstract class, base class của các class ứng dụng thuật toán SM.

class SchedulingAlgorithm: NSObject {
    var eFactor: Double = 0

    private var _qualityResponse: Int = 0

    var qualityResponse: Int {
        get {
            return _qualityResponse
        }
        set {
            _qualityResponse = newValue
            if _qualityResponse < 3 {
                eFactor = defaultEFactor
            }
        }
    }

    var defaultEFactor: Double = 2.5

    func getNextInterval(n: Int) -> Int {
        return 0 // Abstract class
    }

    func getNewEFactor() -> Double {
        return 0 // Abstract class
    }
}

2.2.2. SM2

class SM2: SchedulingAlgorithm {
    override init() {
        super.init()
        eFactor = 2.5
        qualityResponse = 0
    }

    init(eFactor: Double, qualityResponse: Int) {
        super.init()
        self.eFactor = eFactor
        self.qualityResponse = qualityResponse
    }

    override func getNextInterval(n: Int) -> Int {
        if (n==1) {
            return 1
        }
        else if (n==2) {
            return 6
        }
        else if (n>2) {
            return Int(Double(n-1)*eFactor)
        }
        else {
            return 0
        }
    }

    override func getNewEFactor() -> Double {
        var newEFactor: Double = eFactor + (0.1-Double(5-qualityResponse)*(0.08+Double(5-qualityResponse)*0.02))

        if (newEFactor < 1.3) {
            newEFactor = 1.3
        }
        return newEFactor
    }
}

2.2.3. TestSM2

class TestSM2: NSObject {
    func testIntervals() {
        var grades = [0, 3, 4, 5, 5, 1, 4, 5, 5, 5, 4, 3, 4, 5]
        var efs = [ Double(2.5) ]
        var intervals = [1]

        for (index, grade) in enumerate(grades) {
            let sm2 = SM2(eFactor: efs[index], qualityResponse: grades[index])
            let newEF = sm2.getNewEFactor()
            let newInterval = sm2.getNextInterval(index)
            efs.append(newEF)
            intervals.append(newInterval)
        }

        for interval in intervals {
            println(interval)
        }

        for ef in efs {
            println(ef)
        }
    }
}

Hàm testIntervals trả về thời gian lặp lại tiếp theo của đối tượng (tính theo ngày) sau khi chọn câu trả lời, trong đó:

  • grades: mô phỏng sự lựa chọn câu trả lời của người học
  • efs: lưu lại các sự thay đổi eFactor của đối tượng sau mỗi lần người học chọn câu trả lời
  • intervals: lưu lại các thời gian lặp lại

Chạy hàm test trên (có thể đặt trong viewDidLoad của ViewController) ta được kết quả như sau:

override func viewDidLoad() {
        super.viewDidLoad()

        let testSM2 = TestSM2()
        testSM2.testIntervals()
    }

Interval

1
0
1
6
3
4
10
9
11
14
17
20
22
23
25

eFactor

2.5
1.7
1.56
1.56
1.66
1.76
1.96
1.96
2.06
2.16
2.26
2.26
2.12
2.12
2.22

Trong đó các số nguyên 1~25 là interval, 2.5~2.22 là eFactor

2.3. Tạo database CoreData

2.3.1 Card Entity

Screen Shot 2015-06-26 at 8.59.24 AM.png

#import <Foundation/Foundation.h>
#import <CoreData/CoreData.h>
#import "Deck.h"

@class NSManagedObject;

@interface Card : NSManagedObject

@property (nonatomic, retain) NSString * id;
@property (nonatomic, retain) NSDate * creationTime;
@property (nonatomic, retain) NSDate * modificationTime;
@property (nonatomic, retain) NSNumber * type;
@property (nonatomic, retain) NSNumber * queue;
@property (nonatomic, retain) NSNumber * due;
@property (nonatomic, retain) NSNumber * interval;
@property (nonatomic, retain) NSNumber * factor;
@property (nonatomic, retain) NSNumber * reviews;
@property (nonatomic, retain) NSNumber * lapses;
@property (nonatomic, retain) NSString * front;
@property (nonatomic, retain) NSString * back;
@property (nonatomic, retain) Deck *deck;

@end

2.3.2. Deck Entity

Screen Shot 2015-06-26 at 9.02.40 AM.png

#import <Foundation/Foundation.h>
#import <CoreData/CoreData.h>

@class Card;

@interface Deck : NSManagedObject

@property (nonatomic, retain) NSString * id;
@property (nonatomic, retain) NSDate * creationTime;
@property (nonatomic, retain) NSDate * modificationTime;
@property (nonatomic, retain) NSString * title;
@property (nonatomic, retain) NSSet *cards;
@end

@interface Deck (CoreDataGeneratedAccessors)

- (void)addCardsObject:(Card *)value;
- (void)removeCardsObject:(Card *)value;
- (void)addCards:(NSSet *)values;
- (void)removeCards:(NSSet *)values;

@end

2.4. MagicalRecord

Sử dụng thư viện MagicalRecord để giúp đơn giản hoá việc tương tác với CoreData.

Ta sẽ dùng CocoaPod để thêm thư viện MagicalRecord.

pod 'MagicalRecord', '~> 2.3’

Cách cài đặt và sử dụng CocoaPod bạn có thể tham khảo ở trang https://cocoapods.org

2.5. Services

Ta sẽ viết các service class để tương tác với database

2.5.1. CardDto

enum CardType: Int {
    case New = 0
    case Learning = 1
    case Due = 2
}

enum CardQueue: Int {
    case ScheduleBuried = -3
    case UserBuried = -2
    case Suspended = -1
    case New = 0
    case Learning = 1
    case Due = 2
}

class CardDto: NSObject {
    var id: String = ""
    var creationTime: NSDate = NSDate()
    var modificationTime: NSDate = NSDate()
    var deckId: String = ""
    var type = CardType.New
    var queue = CardQueue.New
    var due = 0
    var interval = 0
    var factor: Double = 0
    var reviews = 0
    var lapses = 0

    var front = ""
    var back = ""

    var deck: DeckDto!
}

CardDto sẽ map dữ liệu với Card Entitiy.

2.5.2. DeckDto

class DeckDto: NSObject {
    var id: String = ""
    var creationTime: NSDate = NSDate()
    var modificationTime: NSDate = NSDate()
    var title = ""

    var cards = [CardDto]()
}

2.5.3. Mapper

class Mapper: NSObject {

    class func mapCardDto(cardDto: CardDto, toCard card: Card) {
        card.id = cardDto.id
        card.creationTime = cardDto.creationTime
        card.modificationTime = cardDto.modificationTime
        card.type = cardDto.type.rawValue
        card.queue = cardDto.queue.rawValue
        card.due = cardDto.due
        card.interval = cardDto.interval
        card.factor = cardDto.factor
        card.reviews = cardDto.reviews
        card.lapses = cardDto.lapses
        card.front = cardDto.front
        card.back = cardDto.back
    }

    class func mapCard(card: Card, toCardDto cardDto: CardDto) {
        cardDto.id = card.id
        cardDto.creationTime = card.creationTime
        cardDto.modificationTime = card.modificationTime
        cardDto.type = CardType(rawValue: card.type.integerValue)!
        cardDto.queue = CardQueue(rawValue: card.queue.integerValue)!
        cardDto.due = card.due.integerValue
        cardDto.interval = card.interval.integerValue
        cardDto.factor = card.factor.doubleValue
        cardDto.reviews = card.reviews.integerValue
        cardDto.lapses = card.lapses.integerValue
        cardDto.front = card.front
        cardDto.back = card.back

        if card.deck != nil {
            cardDto.deck = deckDtoFromDeck(card.deck)
            cardDto.deckId = cardDto.deck.id
        }
    }

    class func cardDtoFromCard(card: Card) -> CardDto {
        let cardDto = CardDto()
        mapCard(card, toCardDto: cardDto)
        return cardDto
    }

    class func mapDeckDto(deckDto: DeckDto, toDeck deck: Deck) {
        deck.id = deckDto.id
        deck.creationTime = deckDto.creationTime
        deck.modificationTime = deckDto.modificationTime
        deck.title = deckDto.title
    }

    class func mapDeck(deck: Deck, toDeckDto deckDto: DeckDto) {
        deckDto.id = deck.id
        deckDto.creationTime = deck.creationTime
        deckDto.modificationTime = deck.modificationTime
        deckDto.title = deck.title

        for card in deck.cards {
            let cardDto = cardDtoFromCard(card as! Card)
            deckDto.cards.append(cardDto)
        }
    }

    class func deckDtoFromDeck(deck: Deck) -> DeckDto {
        let deckDto = DeckDto()
        mapDeck(deck, toDeckDto: deckDto)
        return deckDto
    }
}

2.5.4. CardService

class CardService: NSObject {

    func addCard(cardDto: CardDto) {
        MagicalRecord.saveWithBlockAndWait { (context) -> Void in
            let card = Card.MR_createEntityInContext(context)
            Mapper.mapCardDto(cardDto, toCard: card)
        }
    }

    func updateCard(cardDto: CardDto) {
        MagicalRecord.saveWithBlockAndWait { (context) -> Void in
            var predicate = NSPredicate(format: "id = '\(cardDto.id)'")
            let card = Card.MR_findFirstWithPredicate(predicate, inContext: context)
            if card != nil {
                card.modificationTime = NSDate()
                Mapper.mapCardDto(cardDto, toCard: card)
            }
        }
    }

    func deleteCardByCardId(cardId: String) {
        MagicalRecord.saveWithBlockAndWait { (context) -> Void in
            var predicate = NSPredicate(format: "id = '\(cardId)'")
            Card.MR_deleteAllMatchingPredicate(predicate, inContext: context)
        }
    }

    func getAllCards() -> [CardDto] {
        var cardDtos = [CardDto]()
        let cards = Card.MR_findAll()
        for card in cards {
            cardDtos.append(Mapper.cardDtoFromCard(card as! Card))
        }

        return cardDtos
    }

    func getAllCardsByDeckId(deckId: String) -> [CardDto] {
        var cardDtos = [CardDto]()

        var predicate = NSPredicate(format: "deck.id = '\(deckId)'")
        var cards = Card.MR_findAllWithPredicate(predicate)

        for card in cards {
            cardDtos.append(Mapper.cardDtoFromCard(card as! Card))
        }
        return cardDtos
    }
}

2.5.5. DeckService

Ta sẽ bổ sung service này sau.

2.6. Giao diện người dùng

2.6.1. Storyboard

Tại Storyboard, ta tạo các ViewController như sau, gồm có CardListViewController, CardViewController, DeckListViewController, các controller trên kế thừa UITableViewController.

Screen Shot 2015-06-26 at 10.42.23 AM.png

2.6.2. CardViewController

Sử dụng để thêm, sửa thông tin Card

Screen Shot 2015-06-26 at 10.45.19 AM.png

protocol CardViewControllerDelegate: class {
    func cardViewControllerDidSave(sender: CardViewController)
}

class CardViewController: UITableViewController {

    @IBOutlet weak var frontTextField: UITextField!

    @IBOutlet weak var backTextField: UITextField!

    weak var delegate: CardViewControllerDelegate?

    var card: CardDto?

    let cardService = CardService()

    override func viewDidLoad() {
        super.viewDidLoad()

        if let card = card {
            self.title = "Edit Card"
            frontTextField.text = card.front
            backTextField.text = card.back
        }

        frontTextField.becomeFirstResponder()
    }

    // MARK: - Events

    @IBAction func onCancelButtonClicked(sender: AnyObject) {
        self.dismissViewControllerAnimated(true, completion: nil)
    }

    @IBAction func onSaveButtonClicked(sender: AnyObject) {
        if card == nil {
            card = CardDto()
        }

        if let card = self.card {
            card.front = frontTextField.text
            card.back = backTextField.text

            if card.id.isEmpty { // add card
                card.id = NSUUID().UUIDString
                cardService.addCard(card)
            }
            else {
                cardService.updateCard(card)
            }
        }

        delegate?.cardViewControllerDidSave(self)
        self.dismissViewControllerAnimated(true, completion: nil)

    }

    // MARK: - Table view data source

    override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
        return 1
    }

    override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 2
    }
}

2.6.3. CardListViewController

Sử dụng để quản lý card với các chức năng liệt kê, thêm, sửa xoá Card.

Screen Shot 2015-06-26 at 10.50.16 AM.png

class CardListViewController: UITableViewController, CardViewControllerDelegate {

    var cards = [CardDto]()
    var cardService = CardService()

    override func viewDidLoad() {
        super.viewDidLoad()

        loadCards()

        let testSM2 = TestSM2()
        testSM2.testIntervals()
    }

    private func loadCards() {
        cards = cardService.getAllCards()
        tableView.reloadData()
    }

    // MARK: - Events

    @IBAction func onAddButtonClicked(sender: AnyObject) {
        self.performSegueWithIdentifier("addCard", sender: nil)
    }

    @IBAction func onCreateDeckButtonClicked(sender: AnyObject) {

    }

    // MARK: - Table view data source

    override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
        return 1
    }

    override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return cards.count
    }

    override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCellWithIdentifier("CardCell", forIndexPath: indexPath) as! UITableViewCell

        let card = cards[indexPath.row]
        cell.textLabel?.text = card.front
        cell.detailTextLabel?.text = card.back

        return cell
    }

    override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
        let card = cards[indexPath.row]
        self.performSegueWithIdentifier("addCard", sender: card)
    }

    // Override to support editing the table view.
    override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
        if editingStyle == .Delete {
            // Delete the row from the data source
            let card = cards[indexPath.row]
            cardService.deleteCardByCardId(card.id)
            cards.removeAtIndex(indexPath.row)
            tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Fade)
        }
    }

    // MARK: - Navigation

    // In a storyboard-based application, you will often want to do a little preparation before navigation
    override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
        if segue.identifier == "addCard" {
            let controller = (segue.destinationViewController as! UINavigationController).topViewController as! CardViewController
            controller.card = sender as? CardDto
            controller.delegate = self
        }
    }

    // MARK: - CardViewControllerDelegate

    func cardViewControllerDidSave(sender: CardViewController) {
        loadCards()
    }

}

Chạy thử chương trình và thêm 1 vài Card:

iOS Simulator Screen Shot Jun 26, 2015, 10.55.39 AM.png

(Tạm hết phần 1)

Các bạn có thể theo dõi project tại: https://github.com/tuan188/MGFlashcardDemo