本文へスキップ
BecomeCoder

Java MVVMコース · 第6章 ViewModel ― 画面の状態と操作 · レッスン22

TaskListViewModel ― 状態を持つ

ブラウザで完結

導入

いよいよタスク管理アプリのViewModel本体、TaskListViewModelに着手します。画面が必要とする状態は主に3つ――一覧(getTasks())・入力中のタイトル(getNewTitle()setNewTitle())・残タスク数(getRemainingCount())です。これらはすべてTaskServiceから取り出せるので、TaskListViewModelTaskServiceを1つだけ持てば足ります。

図解

classDiagram
    class TaskService {
        +add(title, priority)
        +complete(id)
        +list() List~TaskDto~
        +remainingCount() int
    }
    class TaskListViewModel {
        +getNewTitle() String
        +setNewTitle(value)
        +getTasks() List~TaskDto~
        +getRemainingCount() int
    }
    TaskListViewModel --> TaskService : コンストラクタ注入

説明

getTasks()getRemainingCount()は、状態を自分では持たず、呼ばれるたびにserviceへ問い合わせます。

public class TaskListViewModel extends ViewModelBase {
    private final TaskService service;
    private String newTitle = "";

    public TaskListViewModel(TaskService service) {
        this.service = service;
    }

    public String getNewTitle() {
        return newTitle;
    }

    public void setNewTitle(String value) {
        if (newTitle.equals(value)) return;   // 同じ値なら何もしない
        newTitle = value;
        raisePropertyChanged("newTitle");
        raisePropertyChanged("canAdd");        // 「追加できるか」も一緒に変わったかもしれないので通知する
    }

    public List<TaskDto> getTasks() {
        return service.list();       // 呼ばれるたびに問い合わせる。自分でキャッシュしない
    }

    public int getRemainingCount() {
        return service.remainingCount();
    }
}
  • newTitleの変更は"newTitle""canAdd"の2つのプロパティ名で通知する。入力中のタイトルが変わると「追加できるか」も変わるかもしれないからです(canAddは次のレッスン以降で使います)
  • getTasks()getRemainingCount()は保存されたフィールドを持たず、呼ばれるたびにserviceから最新の結果を取得する。だからTaskService側でタスクが増減すれば、次に呼んだときには必ず最新の状態が返る
  • 同じ値を再セットしたときに何もしない(if (newTitle.equals(value)) return;)のは、無駄な通知を防ぐため

やってみよう

service.add(...)でタスクを追加したあと、vm.getTasks()をもう一度呼ぶと結果が増えていることを、下のテストで確認してみましょう。

演習

エディタは4つのタブに分かれています――TaskListViewModel.java(今回実装するファイル)・Domain.java(第2〜4章の土台)・Service.java(第5章で作ったTaskDtoTaskMapperTaskService)・ViewModelBase.java(前レッスンで完成させた土台)・TaskListViewModelTest.java(テスト)です。

import java.util.List;

// TODO: TaskListViewModel を完成させてください
//       ・コンストラクタで TaskService を受け取り、フィールドに保存する
//       ・getNewTitle() / setNewTitle(value): 変わったら "newTitle" と "canAdd" を通知する
//       ・getTasks(): service.list() をそのまま返す
//       ・getRemainingCount(): service.remainingCount() をそのまま返す
public class TaskListViewModel extends ViewModelBase {
}
import java.util.List;
import java.util.ArrayList;
import java.util.Optional;

public interface IdGenerator {
    String next();
}

public class TaskTitle {
    private final String value;

    public TaskTitle(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("タイトルは空にできません");
        }
        if (value.length() > 50) {
            throw new IllegalArgumentException("タイトルは50文字以内です");
        }
        this.value = value.trim();
    }

    public String getValue() { return value; }

    @Override
    public String toString() { return value; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof TaskTitle)) return false;
        return value.equals(((TaskTitle) o).value);
    }

    @Override
    public int hashCode() { return value.hashCode(); }
}

public enum Priority {
    LOW("低"), MEDIUM("中"), HIGH("高");

    private final String label;

    Priority(String label) { this.label = label; }

    public String getLabel() { return label; }
}

public class TaskItem {
    private final String id;
    private TaskTitle title;
    private final Priority priority;
    private boolean completed;

    public TaskItem(String id, TaskTitle title, Priority priority) {
        this.id = id;
        this.title = title;
        this.priority = priority;
    }

    public String getId() { return id; }
    public TaskTitle getTitle() { return title; }
    public Priority getPriority() { return priority; }
    public boolean isCompleted() { return completed; }
    public void complete() { completed = true; }
    public void rename(TaskTitle newTitle) { title = newTitle; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof TaskItem)) return false;
        return id.equals(((TaskItem) o).id);
    }

    @Override
    public int hashCode() { return id.hashCode(); }
}

public class TaskFactory {
    private final IdGenerator idGenerator;

    public TaskFactory(IdGenerator idGenerator) {
        this.idGenerator = idGenerator;
    }

    public TaskItem create(String title, Priority priority) {
        return new TaskItem(idGenerator.next(), new TaskTitle(title), priority);
    }

    public TaskItem create(String title) {
        return create(title, Priority.MEDIUM);
    }
}

public interface TaskRepository {
    void add(TaskItem task);
    List<TaskItem> findAll();
    Optional<TaskItem> findById(String id);
}

public class InMemoryTaskRepository implements TaskRepository {
    private final List<TaskItem> tasks = new ArrayList<>();

    public void add(TaskItem task) {
        tasks.add(task);
    }

    public List<TaskItem> findAll() {
        return tasks;
    }

    public Optional<TaskItem> findById(String id) {
        for (TaskItem t : tasks) {
            if (t.getId().equals(id)) return Optional.of(t);
        }
        return Optional.empty();
    }
}
import java.util.List;
import java.util.ArrayList;
import java.util.Optional;

public record TaskDto(String id, String title, String priorityLabel, boolean completed) {
}

public class TaskMapper {
    public static TaskDto toDto(TaskItem task) {
        return new TaskDto(task.getId(), task.getTitle().getValue(), task.getPriority().getLabel(), task.isCompleted());
    }
}

public class TaskService {
    private final TaskRepository repository;
    private final TaskFactory factory;

    public TaskService(TaskRepository repository, TaskFactory factory) {
        this.repository = repository;
        this.factory = factory;
    }

    public void add(String title, Priority priority) {
        for (TaskItem t : repository.findAll()) {
            if (t.getTitle().getValue().equals(title)) {
                throw new IllegalStateException("同じタイトルのタスクが既にあります");
            }
        }
        repository.add(factory.create(title, priority));
    }

    public void complete(String id) {
        Optional<TaskItem> found = repository.findById(id);
        if (found.isPresent()) {
            found.get().complete();
        }
    }

    public List<TaskDto> list() {
        List<TaskDto> result = new ArrayList<>();
        for (TaskItem t : repository.findAll()) {
            result.add(TaskMapper.toDto(t));
        }
        return result;
    }

    public int remainingCount() {
        int count = 0;
        for (TaskItem t : repository.findAll()) {
            if (!t.isCompleted()) count++;
        }
        return count;
    }
}
import java.util.ArrayList;
import java.util.List;

public interface PropertyChangedListener {
    void onPropertyChanged(String propertyName);
}

public abstract class ViewModelBase {
    private final List<PropertyChangedListener> listeners = new ArrayList<>();

    public void addPropertyChangedListener(PropertyChangedListener listener) {
        listeners.add(listener);
    }

    protected void raisePropertyChanged(String propertyName) {
        for (PropertyChangedListener listener : listeners) {
            listener.onPropertyChanged(propertyName);
        }
    }
}
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;

public class TaskListViewModelTest {
    @Test
    void serviceNiTsuikaSaretaTaskGaGetTasksDeTorereru() {
        TaskService service = new TaskService(new InMemoryTaskRepository(), new TaskFactory(() -> "t1"));
        service.add("牛乳を買う", Priority.MEDIUM);
        TaskListViewModel vm = new TaskListViewModel(service);

        assertEquals(1, vm.getTasks().size(), "追加した1件が取れる");
        assertEquals("牛乳を買う", vm.getTasks().get(0).title(), "タイトルが一致");
        assertEquals(1, vm.getRemainingCount(), "未完了は1件");
    }

    @Test
    void newTitleWoHenkoSuruto_TsuchiSareru() {
        TaskService service = new TaskService(new InMemoryTaskRepository(), new TaskFactory(() -> "t2"));
        TaskListViewModel vm = new TaskListViewModel(service);
        List<String> notified = new ArrayList<>();
        vm.addPropertyChangedListener(name -> notified.add(name));

        vm.setNewTitle("掃除する");

        assertEquals("掃除する", vm.getNewTitle(), "入力中のタイトルを保持する");
        assertEquals(2, notified.size(), "newTitleとcanAddの2回通知される");
        assertTrue(notified.contains("newTitle"));
        assertTrue(notified.contains("canAdd"));
    }
}
  • 期待される結果: 2件のテストがすべて成功
ヒント1を見る

private final TaskService service; private String newTitle = ""; public TaskListViewModel(TaskService service) { this.service = service; }

ヒント2を見る

public List<TaskDto> getTasks() { return service.list(); } public int getRemainingCount() { return service.remainingCount(); }

まとめ

  • TaskListViewModelTaskServiceを1つだけ持ち、状態はすべてそこへ問い合わせる
  • 「入力中の値」と「表示用の一覧」を分けて考える。前者は保持し、後者はServiceから毎回取得する
  • 1つの値の変更が複数のプロパティ(newTitlecanAdd)に影響するときは、両方を通知する

次回: 一覧だけでは操作できない。画面からの「実行」を表すCommandです。

実際に動かしてみよう

このレッスンのサンプルは、実務と同じように役割ごとの .java ファイルへ分けてあります。下のエディタは最初からその複数ファイルが入った状態で、上のタブでファイルを切り替えられます。そのまま「▶ 実行」を押せば全ファイルをまとめて解釈して動かせます。@Test の付いたテストがあるレッスンでは、テストメソッドごとに ✅/❌ の一覧(Red/Green)が出るので、まずテストを赤くしてから実装で緑にする、というTDDの回し方をその場で体験できます(本物のJVMではなく、JUnit・Mockitoの主要な書き方まで再現した学習用シミュレータです)。

Java — ブラウザ内で実行(学習用シミュレータ)

Javaの教材サブセットを動かす学習用シミュレータを読み込みます(本物のJVMではなく、動きを再現した軽量な自作エンジンです)。
スクロールして表示された時点でも自動で読み込まれます。