本文へ移動
🎨

分類の関数は色ではなく段階を返す:計算と表示の役目を分ける

13分
もくじ

画面のデザインを作り替えたとき、天気や紫外線の値を「良好」から「危険」までの段階に分ける関数が、その段階を塗る色まで返していると分かった。この作りだと、画面の配色を変えるたびに、計算だけをするはずの関数まで直すことになる。配色を変えるときは見た目のコードだけを直し、値を段階に分けるコードには手を入れずに済ませたい。

関数は、値を受け取って、決まった計算をして値を返すコードのまとまりだ。配色は、画面の地(背景)や文字などに使う色の組み合わせを指す。この記事では、地を明るくする「明るい配色」と、地を暗くする「暗い配色」(ダークモードとも呼ぶ)の2つを扱う。

コードを役目ごとに分ける理由と、この記事が扱う場所

アプリのコードは、役目ごとに分けて書く。コードを役目ごとにどう分けて、どこに何を書くかの決め方を、アーキテクチャと呼ぶ。役目ごとに分けたコードのまとまりは、層と呼ぶ。分けておくと、1つの役目を変えたいときに、その層だけを直せば済む。

例として、架空の天気アプリのコードを役目ごとに分ける。表示の層は画面を描き、計算の層は天気の値を段階に分ける。

使う 使う 利用者 表示の層画面を描く何色で塗るかを決める 計算の層天気の値を段階に分ける 天気の値を取ってくる部分外の天気のサービスに問い合わせる

表示の層と計算の層の境目では、「雷雨は段階5」と決めるのが計算の層の仕事で、「段階5は赤で塗る」と決めるのが表示の層の仕事だ。前者はアプリの業務ルールで、後者は見た目の好みである。

配色を変えると分類の関数まで直すことになる

僕のアプリの画面は、デザインがダサかった。すりガラスのように後ろが透けてぼやけるカードを、気温で色が変わる背景のグラデーション(色が少しずつ移り変わる塗り方)の上に重ねていた。そのせいで、明るい配色では色が濁り、カードが背景に沈み、データより飾りのほうが目立っていた。天気を見せるための画面が、天気より背景を見せていたわけだ。

そこで、画面を作り替えると決めて、AI(人工知能)に頼んだ。その作り替えの中で、天気や紫外線の値を段階に分ける関数が、段階ではなく色の値を返していると分かった。この作りだと、配色を変えるたびに、値を分けるだけのはずの関数を開いて、中に書かれた色を書き換えることになる。色を変えたいだけなのに、計算のコードに手が入る。だから、分類の関数が段階を返す形に、AI に分け直させた。

悪い例:分類の関数が色の値を返す

架空の天気アプリで、天気の種類を分ける関数が色を返す形を書くと、こうなる。

// 計算の層
export type WeatherCondition = "clear" | "cloudy" | "rain" | "heavy-rain" | "thunderstorm";

// 天気の種類ごとに、塗る色を持っている
const weatherColors: Record<WeatherCondition, string> = {
  clear: "#2a9d8f",
  cloudy: "#457b9d",
  rain: "#e9c46a",
  "heavy-rain": "#f4a261",
  thunderstorm: "#e63946",
};

export function classifyWeather(condition: WeatherCondition): string {
  return weatherColors[condition];
}

画面の部品は、分類の関数が返した色をそのまま文字に使う。

// 表示の層
import { classifyWeather, type WeatherCondition } from "./weather";

export function WeatherBadge({ condition, label }: { condition: WeatherCondition; label: string }) {
  return <span style={{ color: classifyWeather(condition) }}>{label}</span>;
}

一見、何も困らない。困るのは、色を変えたくなったときだ。

雷雨の赤を少し落ち着かせたいだけでも、直すのは計算の層の classifyWeather のファイルになる。暗い配色を足すなら、もっと面倒になる。この関数に「今は明るい配色か、暗い配色か」という引数(関数を呼ぶときに渡す値)を加え、色を2組持たせることになる。天気を分けるだけの関数が、画面の地の色まで知っていなければならない。天気予報士に、テレビの背景のセットまで組ませているようなものだ。

分類の関数は1〜5の段階を返し、色は表示の側が決める

分類の関数は色を返さず、1(良好)から5(危険)までの段階の数字だけを返す。段階を何色で塗るかは、画面を描く表示の層が決める。こうすると、配色を変えても、計算の層のコードは直さずに済む。

計算の層classifyWeather天気の種類 → 段階 1〜5 表示の層段階を色に置き換える対応表 明るい配色の色 暗い配色の色

計算の層が知っているのは、「大雨は段階4」という業務ルールまでだ。段階4が何色なのかは、表示の層だけが知っている。

良い例:段階を色に置き換える対応表を表示の側に置く

計算の層は、段階だけを返す。

// 計算の層
export type WeatherCondition = "clear" | "cloudy" | "rain" | "heavy-rain" | "thunderstorm";

// 1が良好、5が危険
export type Level = 1 | 2 | 3 | 4 | 5;

const weatherLevels: Record<WeatherCondition, Level> = {
  clear: 1,
  cloudy: 2,
  rain: 3,
  "heavy-rain": 4,
  thunderstorm: 5,
};

export function classifyWeather(condition: WeatherCondition): Level {
  return weatherLevels[condition];
}

このファイルには、色が1つも出てこない。

段階を色に置き換える対応表は、表示の層に置く。明るい配色と暗い配色で、それぞれ別の色を持たせる。明るい配色では明るい地の上で読めるように暗めの色を、暗い配色では暗い地の上で読めるように明るめの色を選ぶ。ここに書いた色の値は例で、実際に読めるかは、地の色との差を確かめて決める。

// 表示の層
import { classifyWeather, type Level, type WeatherCondition } from "./weather";

type Theme = "light" | "dark";

// 段階を何色で塗るかは、表示の層が決める
const levelColors: Record<Theme, Record<Level, string>> = {
  light: { 1: "#1f7a6d", 2: "#2f5f9e", 3: "#9a6a12", 4: "#b0521f", 5: "#a3262a" },
  dark: { 1: "#7fd6c4", 2: "#8fb4ea", 3: "#f0c45a", 4: "#f29a62", 5: "#f07a74" },
};

export function WeatherBadge({
  condition,
  label,
  theme,
}: {
  condition: WeatherCondition;
  label: string;
  theme: Theme;
}) {
  const level = classifyWeather(condition);
  return <span style={{ color: levelColors[theme][level] }}>{label}</span>;
}

この対応表が、段階の色についての配色の定義になる。配色の定義は、明るい配色と暗い配色のそれぞれで、どの色を使うかを1か所にまとめた所だ。雷雨の赤を変えたいときも、暗い配色の色を変えたいときも、直すのはこの表だけである。classifyWeather は開かなくてよい。

段階は、天気以外の分類にも使い回せる。紫外線の値を分ける関数も同じ Level を返せば、同じ対応表で塗れる。僕のコードでも、天気、紫外線、風の強さなど、いくつかの分類が同じ1〜5の段階を返す形にそろった。分類が増えても、色の表は1つのままだ。

配色の定義の外に残っていたグラデーション16個を消した

段階を返す形に直していたとき、計算の層にもう1種類の色が残っていた。背景のグラデーションである。天気の種類に合わせた12個と、日の出と日の入りに合わせた時間帯(夜、朝焼け、日中、夕焼け)の4個で、合わせて16個ある。どれも、色の値をじかに書いたまま、天気や時間帯を分ける関数の近くに置かれていた。

この16個は、配色の定義の外にあった。配色の定義に入っている色には、明るい配色の値と暗い配色の値の2つがある。じかに書かれた16個には、値が1つずつしか無かった。だから、暗い配色の画面でこれらがどう見えるかは、確かめられていなかった。天気を分けるだけの関数が、確かめたことの無い空の色見本帳まで抱えていたわけだ。

16個は消した。分類の関数は、背景の色を返さなくなった。

分類のテストが色の値を見なくなった

テストは、関数が決めたとおりの値を返すかを確かめるコードだ。計算と色を分けると、分類のテストも変わる。

悪い例:色の文字列を確かめるテスト

import { expect, it } from "vitest";
import { classifyWeather } from "./weather";

it("大雨の色を返す", () => {
  expect(classifyWeather("heavy-rain")).toContain("#");
});

このテストが確かめているのは、色らしい文字列が返ることまでだ。大雨がどの段階に分けられたのかは見ていない。しかも、配色を直すついでに色の書き方を変えれば、このテストまで直すことになる。

良い例:段階を確かめるテスト

import { expect, it } from "vitest";
import { classifyWeather } from "./weather";

it("大雨は段階4", () => {
  expect(classifyWeather("heavy-rain")).toBe(4);
});

こちらは、分類の関数が本来受け持つ「どの段階に分けたか」を確かめている。配色をいくら変えても、このテストは直さなくてよい。

僕のコードでも、同じことが起きた。紫外線の分類のテストは、返ってきた色の文字列に、色の書き方の名前が含まれるかを確かめていた。段階を返す形に変えたあとは、段階が4かを確かめるようになった。日の出と日の入りの時間帯のテストからも、背景の色の値を含むかを見る行が消え、4つの時間帯の名前(夜、朝焼け、日中、夕焼け)を確かめる形になった。

色を返していたころのテストは、関数が何色を返すかに縛られていた。段階だけを返すようにしたら、テストも計算のことだけを見るようになった。

段階だけを返せば、配色の変更は計算のコードに届かない

値を段階に分ける関数は、色を返さずに1〜5の段階だけを返す。段階を色に置き換える仕事は、画面を描く表示の層の対応表に任せる。そうすると、配色を変えても、明るい配色と暗い配色を足しても、直すのは対応表だけで、計算のコードには手が入らない。テストも、色ではなく段階を確かめるようになる。

関数が返す値に色が混ざっていたら、それは見た目の話が計算の層に入り込んでいる印だ。

おわり😊

RELATED

つくることで、見える景色がある。