導入
いよいよタスク管理アプリのViewModel本体、TaskListViewModelに着手します。画面が必要とする状態は主に3つ――一覧(getTasks())・入力中のタイトル(getNewTitle()/setNewTitle())・残タスク数(getRemainingCount())です。これらはすべてTaskServiceから取り出せるので、TaskListViewModelはTaskServiceを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章で作ったTaskDto/TaskMapper/TaskService)・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(); }
まとめ
TaskListViewModelはTaskServiceを1つだけ持ち、状態はすべてそこへ問い合わせる- 「入力中の値」と「表示用の一覧」を分けて考える。前者は保持し、後者はServiceから毎回取得する
- 1つの値の変更が複数のプロパティ(
newTitleとcanAdd)に影響するときは、両方を通知する
次回: 一覧だけでは操作できない。画面からの「実行」を表すCommandです。