目次

目次

【Kotlin】レコチョクのAndroidエンジニアになるまで

瀬川 亮
瀬川 亮
最終更新日2025/11/25 投稿日2024/12/10

この記事はレコチョク Advent Calendar 2024 の10日目の記事になります。

はじめに

株式会社レコチョクのAndroidアプリ開発グループに所属している瀬川です。

先日 「進撃の巨人」完結編 The Last Attackを映画館で視聴し、大画面・大音量の進撃の世界に圧倒されたことで、最近は「進撃の巨人」のサウンドトラックにどハマりしております。

そんな私は社会人になってから4年経ちましたが、実はレコチョクに入社してから1ヶ月ちょっとしか経っていない 新米アプリエンジニアなんです。

前職ではShopifyで構築されたECサイトのコーディングやWordpressサイトの改修などを行っており、使用していた言語は HTML/CSS/Javascipt/Liquidなのですが、Androidアプリ開発は Kotlin/Javaがメインなので全く違う言語&未経験での転職となりました。

この記事では、そんな私がレコチョクに Androidアプリエンジニアとして転職するまでに 学習した内容と流れ / 学習方法などを紹介します。

この記事で伝えたいこと

若干自伝のようなタイトルにもなってしまいましたが、この記事の目的は1人でも多くのAndroidアプリ開発の 勉強を頑張っている方・勉強を始めようとしている方、言語は異なるけど アプリ開発を学習中の方など、

現在エンジニア就職/転職を目指している方

に少しでも参考になれれば良いなと考えています。

そのためあえて自伝のように今までやってきたことを振り返り、学習方法の参考にもなれれば良いですが、何より やる気と行動力さえあれば「誰でも挑戦できる」という再現性を感じていただき、読んでいるあなたのやる気に少しでも繋がってくれると良いです!

実際の学習内容と流れ

では早速私が Androidアプリエンジニアとしてレコチョクに転職するまでに学習した内容とその流れを説明します。

そもそもなぜアプリエンジニアを目指したかについてですが、以前東南アジアで半年ほど「ノマドエンジニア」をしていた際に、タクシーアプリや宅配アプリ、翻訳アプリなどに毎日のように生活を救われていたからです。 「スマホアプリが人の生活の支えになる」ということを体感するには十分すぎる環境だったため、 人の支えになるアプリ開発をしたいという想いがその期間に芽生えアプリエンジニアを目指すことになりました。

なぜ「Android」かという点については、全世界ではAndroidのシェア率が70%前後(私はiPhoneユーザー)と非常に多くの方がAndroidスマホを毎日使用しています。 そんな情報を目にした私は安直ではありますが、 自分のアプリ開発という行動が世界に影響を与える割合はAndroidの方が大きいというシンプルな発想をし、Androidアプリエンジニアを目指すことにしました。

ということで、Androidアプリ開発の主流言語である Kotlinを学習していきました。 Androidアプリ開発においてはJavaが主流だったものの、2017年にGoogleがKotlinを公式採用したことで一気に人気になり、今やKotlinが主流言語となっています。

加えて、Googleが提供するUIフレームワークである Jetpack ComposeをUI開発のため学習していきました。こちらもAndroidアプリ開発におけるモダンな技術として使用されています。

学習の流れ(全体像)

私がAndroidアプリ開発の学習を始めてから転職に至るまでの流れです。

・開発環境を構築 ・【基礎知識】Google公式のトレーニングコース ・いざ自作アプリ開発 ・開発したアプリをGitHubで公開&ポートフォリを作成

レコチョクに転職するまでこの流れを経て学習しました。 では1つずつ説明します。

開発環境を構築

まずは開発環境の構築をしました。 環境構築は簡単で、PCに統合開発環境(Android Studio または IntelliJ IDEA)をインストールさえすればAndroidアプリ開発をすぐに始めることができます。

私はパッケージがおしゃれに感じたのでInteliji IDEAを使用していましたが、Android StudioはGoogleが公式に提供しているIDEで、Androidアプリ開発に必要なものは初めから揃っているため、とくにこだわりがなければAndroid Studioをオススメします!

【基礎知識】Google公式のトレーニングコース

開発環境が整ったら早速学習を始めました。 まずは インプットを最優先とし、Google公式が用意してくれているAndroidアプリ開発のトレーニングコースを利用して学習していきました。

このコースは Jetpack Compose/Kotlinの両方の基礎学習を網羅していて、2つの技術を使用してAndroidアプリを開発していく体験ができるコースになっています。

まずはコースで学習した内容について1つずつ簡単に説明します。

Kotlinの基礎文法

image-20241204011314295.png

まずはこのコースで学習した Kotlinの基礎文法についてソースコードと共に紹介します。

変数 / 関数

変数は「情報」を一時的に保存するための箱のようなものです。定義の仕方によって変更できるもの・できないものなど仕様が異なります。

関数は「特定の処理をまとめたもの」です。

fun 関数名(){} の形で定義し 関数名()で関数を呼び出す/使用することができます。

// 変数
val myName = "瀬川" // 変えられない値「val」
var myAge = 26       // あとで変えられる値「var」

println( "私は$myNameです!$myAge 歳です。") // 実行結果:私は瀬川 です!26歳です。

myAge = 27  //varで定義しているので値を変更することができる
println( "私は$myNameです!$myAge 歳です。") // 実行結果:私は瀬川 です!27歳です。

// 関数
fun greet() {
    println( "私は瀬川です!")
}
greet() // 実行結果:私は瀬川です!

また、関数は特定のデータ(パラメータ)を受け取るようにすることができ、関数を実行するときにデータを渡すことでデータによって処理を変化させることもできます。

// パラメータを渡して処理を変えることができる関数
fun greetWithName(name: String) {
    println("私は$nameです!")
}

// 呼び出す時にnameに渡したい文字列を指定できる
greetWithName(name = "瀬川") // 実行結果:私は瀬川です!
greetWithName(name = "太郎") // 実行結果:私は太郎です!

また関数は処理を実行後処理に応じた結果を返すこともできます。

// 処理の結果を返すことができる関数
fun calculateBirthYear(age: Int): Int {
    val currentYear = 2024
    return currentYear - age
}

val birthYear = calculateBirthYear(26)
println("生まれた年は $birthYear 年です!") //  実行結果:生まれた年は 1998 年です!

条件分岐(if / when式)

条件分岐は、特定の状況に応じて異なる処理を行いたい場合に使用します。 if文は特に利用頻度が多いため覚える必要のある基礎知識となります!

// if文
val age = 20

if (age >= 18) {
    //()内の条件がtrue(条件が成立している)の場合
    println("成人です") // 18歳以上ならこちら
} else {
     //()内の条件がfalseの場合
    println("未成年です") // 18歳未満ならこちら
}
// ageには20が代入されているので、結果は「成人です」がプリントされる

// when文
val grade = "A"

when (grade) {
    "A" -> println("最高です!") // 成績がAの場合
    "B" -> println("良い感じです!") // 成績がBの場合
    else -> println("もう少し頑張りましょう!") // それ以外の場合
}

// gradeにはAが代入されているので、結果は「最高です!」がプリントされる

クラス

クラスは「データ」とそのデータを使用して動作する「関数」をまとめたものです。

// クラスの書き方
class Person(val name: String, var age: Int) {
    // 挨拶の関数
    fun greet() {
        println("こんにちは、私は $name です。年齢は $age 歳です!")
    }
}

val person1 = Person("瀬川", 26)
val person2 = Person("太郎", 18)
person1.greet() // 実行結果:こんにちは、私は 瀬川 です。年齢は 26 歳です!
person2.greet() // 実行結果:こんにちは、私は 太郎 です。年齢は 18 歳です!

Null許容変数

Nullとは? nullは「何もない状態」を意味します。変数の箱に何も入っていない、または「空っぽ」な状態を表します。

変数にnullを格納する可能性がある場合、そのままの変数ではnullエラーになってしまうため、型指定時に?(例:String?)を付けることで許容する変数として定義することができます。

// 非Null型(String)の宣言
// 初期値にnullを代入しようとするとコンパイルエラー
val name: String = null // エラー: Null can not be a value of a non-null type String

//  Null許容型(String?)変数を宣言する場合はnullを代入できる
val name: String? = null

// 上で作成したnullableName変数の文字数を取得する際、Null許容型では様々な呼び出し方法がある

// 1. 安全呼び出し演算子(?.
// 値がnullの場合はその場でnullを返す
val lengthUsingSafeCall = name?.length
println("安全呼び出し演算子で取得した長さ: $lengthUsingSafeCall") // 実行結果:null

// 2. エルビス演算子(?:)でデフォルト値を設定
val lengthWithDefault = name?.length ?: 0
println("エルビス演算子で設定した長さ: $lengthWithDefault") // 実行結果:0

// 3. 非Nullアサーション(!!)
// 強制的にnullでないと保証して変数を使用できますがnullの場合はエラーが発生するため非推奨
val lengthUsingNonNullAssertion = name!!.length
println("非Nullアサーションで取得した長さ: $lengthUsingNonNullAssertion") // 実行結果:エラーになる

List/Map/高階関数

ListやMapは「コレクション」と呼ばれるデータ構造のことです。コレクションとは、「複数のデータをまとめて扱う仕組み」のことです。

また「高階関数」とは、コレクションを便利に操作できる関数で各コレクションにデフォルトで用意されています。たとえば、「特定の条件に合うデータだけを取り出す」といった操作が簡単に行えます。

// リスト: 順序付きのデータコレクション
val fruits = listOf("りんご", "バナナ", "みかん")

// インデックスを指定することで要素を取得できる
println("${fruits[0]}") // 実行結果:りんご
println("${fruits[2]}") // 結果:みかん

// 高階関数: リストの操作
// 例: sorted(要素をアルファベット順に並び替えることができます)
val sortedFruits = fruits.sorted()
println("$sortedFruits") // 実行結果 [みかん, りんご, バナナ]

// マップ: キーと値のペア
val scores = mapOf("亮" to 80, "太郎" to 100)

// キーを指定することで値を取得できる
println("亮のスコア: ${scores["亮"]}") // 実行結果: 亮のスコア: 80
println("太郎のスコア: ${scores["太郎"]}") // 実行結果: 太郎のスコア: 100

// 高階関数: マップの操作
// 例: filter(条件を満たすキーと値のペアだけを抽出することができます)
val highScores = scores.filter { it.value > 90 }
println("$highScores") // 実行結果: {太郎=100}

JetpackComposeの基礎知識

image-20241204011509948.png

続いてこのコースで学習した【Jetpack Composeの基礎知識】について紹介していきます。

Text / Image / Icon

// テキストの表示
Text(text = "表示したいテキスト")

// 画像の表示
Image(
    painter = painterResource(id = R.drawable.ic_launcher_foreground),
    contentDescription = "画像の説明"
)

// アイコンの表示
Icon(
    imageVector = Icons.Default.Home,
    contentDescription = "アイコンの説明"
)

// 文字の大きさや色も指定することも可能
Text(
    text = "表示したいテキスト",
    color = Color.Red,  //色を赤色に指定
    fontSize = 18.sp, //文字の大きさを指定
)

[サンプル]

image-20241204031235629.png

Column / Row

// 要素を縦に並べるColumn
Column {
    Text(
        text = "上に表示",
        color = Color.Red
    )
    Text(
        text = "下に表示",
        color = Color.Blue
    )
}

// 要素を横に並べるRow
Row {
    Text(
        text = "左に表示",
        color = Color.Red
    )
    Text(
        text = "右に表示",
        color = Color.Blue
    )
}

// 重ねて使用することで複雑なレイアウトの作成も可能!
Column {
    Row {
        Text(
            text = "左上に表示",
            color = Color.Red
        )
        Text(
            text = "右上に表示",
            color = Color.Blue
        )
    }
    Row {
        Text(
            text = "左下に表示",
            color = Color.Green
        )
        Text(
            text = "右下に表示",
            color = Color.Gray
        )
    }
}

[サンプル]

column_row.png

Box

Box(
    modifier = Modifier
        .size(100.dp)
        .background(Color.DarkGray),
    contentAlignment = Alignment.Center // 中央揃え 
) {
    Text(
        text = "テキスト",
        color = Color.White
    )
}

[イメージ]

box.png

Button

Button(
    onClick = { /* ボタンが押されたときの処理(関数を設定することも可能) */ },
) {
    Text(
        text = "クリック"
    )
}

[サンプル]

image-20241204031417476.png

Modifierについて

// Textにmodifierを適用した例
Text(
    text = "表示したいテキスト",
    modifier = Modifier
        .padding(16.dp) // 内側に余白を追加
        .background(Color.LightGray) // 背景色を設定
        .fillMaxWidth() // 横幅を親要素に合わせる
)

// BoxにModifierを適用した例
Box(
    modifier = Modifier
        .size(100.dp) // 高さと幅を指定
        .background(Color.Cyan) // 背景色を設定
        .clip(RoundedCornerShape(8.dp)) // 角を丸める
        .border(2.dp, Color.Black) // 枠線を追加
)

[サンプル]

image-20241204031427971.png

その他にもこんなModifierがあります

・ fillMaxSize()|幅と高さを親要素いっぱいに広げる ・ fillMaxHeight()|高さを親要素いっぱいに広げる ・clickable()|タップ時のイベントを設定 ・ align() |子要素の配置を指定

スクロール可能なリスト(LazyColumnなど)

LazyColumn(
    modifier = Modifier.fillMaxSize()
) {
    items(100) { index ->
        Text(
            text = "アイテム $index",
            modifier = Modifier.padding(16.dp)
        )
    }
}

[サンプル]

カスタムのコンポーネントの作成もできる

作成サンプル

// カスタムボタンの作成
@Composable
fun CustomButton(
    text: String, // ボタンに表示する文字
    backgroundColor: Color = Color.Blue, // ボタンの背景色
    textColor: Color = Color.White, // ボタンの文字色
    onClick: () -> Unit // ボタンを押したときの処理
) {
    Button(
        onClick = onClick, // ButtonのonClickにCustomButtonのonClick処理を指定
        colors = ButtonDefaults.buttonColors(containerColor = backgroundColor), // 背景色を設定
        modifier = Modifier
            .padding(8.dp) // 外側の余白
            .fillMaxWidth() // 横幅を画面いっぱいに広げる
    ) {
        Text(text = text, color = textColor) // ボタンに表示するテキスト内容と色を指定
    }
}

実際に使う場合

// CustomButtonを使用する 
CustomButton(
    text = "ボタン", // ボタンに表示するテキスト
    onClick = {
        println("ボタンが押されました!") // ボタンが押されたときの処理
    },
    backgroundColor = Color.Green, // ボタンの背景を緑に変更
    textColor = Color.Black // テキストの色を黒に変更
)
// 呼び出し時に設定したテキストや関数・色がCustomButtonの中にある【Button / Text】で使用されます。
image-20241204032451948.png

【プチ応用】両技術を組み合わせて変化するUIを作成

ここからは少し応用になりますが、ユーザーの動き(タップなど)に合わせて変化するUIを開発する一例として SteteFlowを使用した状態管理を紹介します。 StateFlowは、アプリで使用する要素の「状態」を管理し、その状態が変わったときにUIへリアルタイムで反映させるために使う仕組みです。

StateFlowを使用しない場合、例えば変数に新しい値を代入してもComposeの再描画(リコンポーズ)がトリガーされていないため、UIには変更が反映されません。

StateFlowを使用しない場合
// 変更可能な変数を定義
var animal = "イヌ"

// 動物の名前を表示
Text(
    text = "動物: $animal"
)

// ボタンを押すとanimalに"ネコ"が代入される(animalの値が変わる)
Button(onClick = {
    animal = "ネコ"
}) {
    Text(
        text = "テキストを変える"
    )
}

このコードではButtonを押すことでanimalに「ネコ」という文字列が代入されanimalの内容は変わりますが、UIは「イヌ」のままになってしまいます。

StateFlowを使用した場合
// ViewModelで状態を管理する場合
class AnimalViewModel {
    private val _animal = MutableStateFlow("イヌ") // テキストの初期値は「イヌ」
    val animal: StateFlow<String> = _animal.asStateFlow() // UIで読み取る際に使用

    // テキストを変える
    fun changeAnimal() {
        // _animal.value に値(String型)を代入することで状態の変更が可能
        _animal.value = "ネコ"
    }
}

// StateFlowを使用すればUIに変更が即座に反映されます

//Compose側で状態を受け取る変数を定義
val animalState by viewModel. animal.collectAsState()

//動物の名前を表示
Text(
    text = "動物: $animalState"
)

// ボタンを押すと「changeAnimal関数」が呼び出されてテキストが変わる
Button(onClick = { viewModel.changeAnimal() }) {
    Text(
        text = "テキストを変える"
    )
}

このコードの場合Buttonを押したらStateFlowで監視していた変数の値が変わることで、Composeが再描画され、UIに変更が即座に反映されます。

基礎学習まとめ

これまで紹介した基礎知識以外にも、コルーチン(非同期処理)やラムダ式なども学習しました。 このコースではセクション毎に用意された個別のアプリを開発していながら、KotlinとJetpacl Composeの基礎知識を学習していくため、楽しく飽きずに学習を進めることができます!

また、程よくボリュームもあり、毎セクション終わった後には充実感も感じることができ、得られる知見や継続しやすいことも含めて「無料」とは思えないものでした。

このコースはまだ学習始めたての方にはとくにオススメですが、 これ以外にもトレーニングコースは用意されているので、自分に合ったコースを探してみても良いかもしれません。

いざ自作アプリ開発

Google公式のトレーニングコースを終え基礎知識を幅広く インプットできたところで、次は自作アプリ開発(実践経験)でアウトプットの量を増やしていくことにしました。

自作アプリ開発は自分が作りたいアプリを作る過程で、

・「アウトプット」することで基礎知識の定着 ・いざ作ってみないとわからない実践技術の「インプット」

の両方を叶えることができます。 エンジニアを目指している方は力をつけるのにもってこいですので、ぜひ挑戦してみてください。

転職するまでに開発したアプリ

・電卓アプリ ・タスク登録/管理アプリ(ツミアゲ) ・クイズアプリ など

今回はその中でも 電卓アプリについて紹介します。

電卓アプリ

電卓アプリは アプリエンジニアの登竜門という話を聞き、1つ目は電卓アプリを開発しました。 内容は至ってシンプルな四則演算・小数点以下の計算ができる電卓アプリとなります。

開発を始める前に

・アプリのレイアウト ・想定される処理内容 をまずは紙に簡単に書き起こしました。(これ大事💡)

例えば

  1. 必要な画面の数や内容(SNSアプリであれば、ホーム画面・投稿画面・プロフィール画面など)
  2. このボタンを押したら何が起きるべきか など、頭の中でなんとなくイメージをしながら書くことが大切だと思います。

今回の電卓アプリであれば、 それっぽいUIをiPhoneの標準の電卓アプリを見ながらUIのイメージを書いた後、

1+3であれば「3」を押したときは前に入力した「1」を使用して足したい「ということは「1」はどこかにとっておかないといけないな」というように機能をイメージをすることで、必要なこと(関数で処理したいこと)がなんとなくわかってきます。 このような小さいこともメモをしておくことで、なんとなくでもイメージができておりスムーズに開発中が進んでいた覚えがあります。

アプリ開発を学習中の方で「これから自作アプリを開発してみよう」と考えている方は、 このように開発を始める前にまずはアプリの全体像を簡単でも良いので紙に書き起こすことから始めてみてください!

使用技術

Googleのトレーニングコースで学習したように下記の技術を使用して開発しました。

  • Jetpack Compose
  • Kotlin

使用したライブラリ

今回機能面の実装はViewModel内で行ったため、ViewModelのライブラリを依存関係に追加しました。 また画面サイズに応じてレイアウトを少しだけ変更したかったため、window-size-classも追加しました。

dependencies {
    implementation("androidx.core:core-ktx:1.13.1")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0")
    implementation("androidx.activity:activity-compose:1.9.0")
    implementation(platform("androidx.compose:compose-bom:2023.08.00"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.compose.material3:material3")
    // 今回追加したライブラリ
    // ViewModelを使用するためのライブラリ
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0")
    // デバイスの画面サイズに応じてレイアウトを調整するためのライブラリ
    implementation("androidx.compose.material3:material3-window-size-class")
}

ソースコード

UI

アクティビティ|MainActivity.kt

class MainActivity : ComponentActivity() {
    @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            CalculatorTheme {
                Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
                    // 画面サイズクラスを計算
                    val windowSize = calculateWindowSizeClass(this)
                    // ホーム画面のコンポーズを表示
                    CalculatorHomeScreen(windowSize = windowSize.widthSizeClass)
                }
            }
        }
    }
}

ホーム画面(メイン)|CalculatorHomeScreen.kt

package com.example.calculator.ui

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalculatorHomeScreen(
    windowSize: WindowWidthSizeClass,  // 画面サイズクラスを渡す
    viewModel: CalculatorViewModel = CalculatorViewModel()  // ViewModelを使用
) {
    // 入力テキストの状態を監視
    val inputTextValue by viewModel.inputTextState.collectAsState()
    // 演算タイプの監視
    val calculateTypeText by viewModel.calculateTypeText.collectAsState()
    // 演算子ボタンの背景色を設定
    val calculateColor = 0xFF001A74

    // デバイスサイズに応じた余白調整
    val topSpace: Float = when(windowSize) {
        WindowWidthSizeClass.Compact -> 0.5F
        WindowWidthSizeClass.Medium -> 0.4F
        WindowWidthSizeClass.Expanded -> 0.01F
        else -> 0.5F
    }

    Scaffold(
        topBar = {
            CenterAlignedTopAppBar(
                title = { Text("デンタク") }
            )
        }
    ) {paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)  // Scaffoldから渡された余白を適用
        ) {
            Spacer(modifier = Modifier.weight(topSpace))  // 上部のスペースを動的に調整
            Row(
                modifier = Modifier.padding(start = 4.dp)
            ){
                // 演算子の表示
                Box(
                    modifier = Modifier.clip(CircleShape).background(Color(0xFF001A74))
                ) {
                    Text(
                        calculateTypeText,
                        fontSize = 24.sp,
                        color = Color.White
                    )
                }
                // 入力値の表示
                Text(
                    inputTextValue,
                    fontSize = 50.sp,
                    lineHeight = 1.em,
                    modifier = Modifier.padding(16.dp)
                )
            }
            // デバイスごとに異なるレイアウトを選択
            if(windowSize == WindowWidthSizeClass.Compact || windowSize == WindowWidthSizeClass.Medium) {
                PhoneLayout(
                    viewModel = viewModel,
                    calculateColor = calculateColor,
                    modifier = Modifier.weight(1F)
                )
            } else if(windowSize == WindowWidthSizeClass.Expanded) {
                PcLayout(
                    viewModel = viewModel,
                    calculateColor = calculateColor,
                    modifier = Modifier.weight(1F)
                )
            }
        }
    }
}

// ボタンの再利用可能なコンポーズ
@Composable
fun ContentButton(
    text: String,  // ボタンのラベル
    onClick: () -> Unit,  // ボタンのクリック時の処理
    textColor: Color = Color.White,   // ボタン内のテキスト色(デフォルトは白)
    containerColor: Color = Color.DarkGray,  // ボタンの背景色(デフォルトはダークグレー)
    modifier: Modifier = Modifier
) {
    Button(
        onClick = onClick,
        modifier = modifier
            .padding(4.dp)
            .fillMaxSize(),
        colors =  ButtonDefaults.buttonColors(
            containerColor = containerColor  // ボタンの背景色を設定
        )
    ) {
        Text(
            text,
            fontSize = 28.sp,
            color = textColor  // ボタン内のテキスト色を設定
        )
    }
}

スマホレイアウト(今回はタブレットレイアウトは省略)

@Composable
fun PhoneLayout(
    viewModel: CalculatorViewModel,  // 電卓のロジックを管理するViewModel
    calculateColor: Long,  // 演算子ボタンの背景色
    modifier: Modifier = Modifier
) {
    Column(modifier = modifier) {
         // 1行目のボタンを配置
        Row(modifier = Modifier.weight(1f)) {
            ContentButton(
                modifier = Modifier.weight(1f),
                onClick = {
                    viewModel.inputNumber(7.0)  // 数字入力時の関数(以下数字ボタンは同様)
                },
                text = "7"
            )
            ContentButton(
                modifier = Modifier.weight(1f),
                onClick = {
                    viewModel.inputNumber(8.0)
                },
                text = "8"
            )
            ContentButton(
                modifier = Modifier.weight(1f),
                onClick = {
                    viewModel.inputNumber(9.0)
                },
                text = "9"
            )
            // 割り算ボタン
            ContentButton(
                modifier = Modifier.weight(1f),
                onClick = { viewModel.divideNumber() },  // 割り算ボタンの関数を呼び出す
                containerColor = Color(calculateColor),   // 演算子ボタン用の色に設定
                text = "÷"
            )
        }
        // 2行目のボタンを配置
        Row(modifier = Modifier.weight(1f)) {
            ContentButton(
                modifier = Modifier.weight(1f),
                onClick = {
                    viewModel.inputNumber(4.0)
                },
                text = "4"
            )
            ContentButton(
                modifier = Modifier.weight(1f),
                onClick = {
                    viewModel.inputNumber(5.0)
                },
                text = "5"
            )
            ContentButton(
                modifier = Modifier.weight(1f),
                onClick = {
                    viewModel.inputNumber(6.0)
                },
                text = "6"
            )
             // 掛け算ボタン
            ContentButton(
                modifier = Modifier.weight(1f),
                onClick = { viewModel.multiplyNumber() },  // 掛け算ボタンの関数を呼び出す
                containerColor = Color(calculateColor),   // 演算子ボタン用の色に設定
                text = "×"
            )
        }
        // 3行目のボタンを配置
        Row(modifier = Modifier.weight(1f)) {
            ContentButton(
                modifier = Modifier.weight(1f),
                onClick = {
                    viewModel.inputNumber(1.0)
                },
                text = "1"
            )
            ContentButton(
                modifier = Modifier.weight(1f),
                onClick = {
                    viewModel.inputNumber(2.0)
                },
                text = "2"
            )
            ContentButton(
                modifier = Modifier.weight(1f),
                onClick = {
                    viewModel.inputNumber(3.0)
                },
                text = "3"
            )
             // 引き算ボタン
            ContentButton(
                modifier = Modifier.weight(1f),
                onClick = { viewModel.minusNumber() },  // 引き算ボタンの関数を呼び出す
                containerColor = Color(calculateColor),   // 演算子ボタン用の色に設定
                text = "-"
            )
        }
        // 4行目のボタンを配置
        Row(modifier = Modifier.weight(1f)) {
            ContentButton(
                modifier = Modifier.weight(2f),
                onClick = {
                    viewModel.inputNumber(0.0)
                },
                text = "0"
            )
            //小数点の追加
            ContentButton(
                modifier = Modifier.weight(1f),
                onClick = {
                    viewModel.changeToDecimal()  // 小数点の処理に変更する関数
                },
                text = "."
            )
             // 足し算ボタン
            ContentButton(
                modifier = Modifier.weight(1f),
                onClick = { viewModel.plusNumber() },   // 足し算ボタンの関数を呼び出す
                containerColor = Color(calculateColor),   // 演算子ボタン用の色に設定
                text = "+"
            )
        }
        // 5行目のボタンを配置
        Row(modifier = Modifier.weight(1f)) {
            //クリアボタン
            ContentButton(
                modifier = Modifier.weight(3f),  // 3/4のレイアウトがクリアボタンになる
                onClick = { viewModel.clearCalculator() },  // クリアボタンの関数を呼び出す
                textColor = Color(0xFF444444),
                containerColor = Color(0xFFFFC825),
                text = "C"
            )
            // 計算結果ボタン
            ContentButton(
                modifier = Modifier.weight(1f),  // 1/4のレイアウトがクリアボタンになる
                onClick = { viewModel.sumUpCalculate() },  // 計算結果を表示する関数を呼び出す
                containerColor = Color(0xFF168000),
                text = "="
            )
        }
    }
}

機能実装

ViewModel(CalculatorViewModel.kt)

package com.example.calculator.ui

import androidx.lifecycle.ViewModel
import com.example.calculator.data.CalculateType
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

class CalculatorViewModel:ViewModel() {

    // 今入力されている数字を管理(例: 12や3.5)。最初は何も入力されていないからnull
    private val _inputNumberState:MutableStateFlow<Double?> = MutableStateFlow(null)
    val inputNumberState:StateFlow<Double?> = _inputNumberState.asStateFlow()

    // 画面に表示するテキストを管理
    private val _inputTextState:MutableStateFlow<String> = MutableStateFlow("0")
    val inputTextState:StateFlow<String> = _inputTextState.asStateFlow()

    // 計算中の合計値を管理(足し算や掛け算の途中の結果も含める)
    private val _totalState:MutableStateFlow<Double?> = MutableStateFlow(null)
    val totalState:StateFlow<Double?> = _totalState.asStateFlow()

    // 現在選択されている演算子(+、-、×、÷)を管理
    private val _calculateType: MutableStateFlow<CalculateType?> = MutableStateFlow(null)

    // 演算子の記号をテキストとして画面に表示するための状態
    private val _calculateTypeText: MutableStateFlow<String> = MutableStateFlow("")
    val calculateTypeText: StateFlow<String> = _calculateTypeText.asStateFlow()

    // 小数点入力中かどうかを管理(小数点モードのON/OFFスイッチ)
    private val _decimalMode: MutableStateFlow<Boolean> = MutableStateFlow(false)

    // 数字ボタンが押されたときの処理
    fun inputNumber(num: Double) {
        // すでに何か入力されている場合
        if(_inputNumberState.value != null){
            if(_decimalMode.value) {
                // 小数点が入力された後なら、小数として扱う
                val previousNumber: String = formatNumber(_inputNumberState.value!!)
                val newNumberText: String = formatNumber(num)
                val newNumber: String = previousNumber.plus(".$newNumberText")
                _inputNumberState.value = newNumber.toDouble()
                _inputTextState.value = newNumber
                _decimalMode.value = false  // 小数点モード終了
            } else {
                // 通常の数字を追加する
                // 23にしたい場合|すでに2が入力されていて3を付け足す際、一度文字列に変換して2の後ろに3をつけて、その後Double型に変換している)

                // 文字列に変換
                val newNumberText: String = formatNumber(num)
                 // 文字列として入力した数字を追加
                val newNumber = _inputTextState.value.plus(newNumberText)
                 // 計算用のDouble型数字の値を更新
                _inputNumberState.value = newNumber.toDouble()
                 // UI表示用の文字列数字の値を更新
                _inputTextState.value = newNumber
            }
        } else {
            // 初めて数字を入力する場合
            _inputNumberState.value = num
            _inputTextState.value = formatNumber(num)
        }
    }

    // 足し算ボタンが押されたとき
    fun plusNumber() {
        calculateNumber(CalculateType.PLUS)
    }

    // 引き算ボタンが押されたとき
    fun minusNumber() {
        calculateNumber(CalculateType.MINUS)
    }

    // 掛け算ボタンが押されたとき
    fun multiplyNumber() {
        calculateNumber(CalculateType.MULTIPLY)
    }

    // 割り算ボタンが押されたとき
    fun divideNumber() {
        calculateNumber(CalculateType.DIVIDE)
    }

    // 各計算タイプに応じた計算処理を記載
    private fun calculateNumber(
        thisCalculateType: CalculateType
    ) {
        val inputValue = _inputNumberState.value  // 現在入力されている値
        val totalValue = _totalState.value  // これまでの合計数値

        if(inputValue != null) {
            if(_calculateType.value != null && totalValue != null) {

                // すでに演算子が選択されている場合計算を続ける
                // 例えば、「23 + 12 + 15」をしたい場合で「23」と「+」がすでに押されていて、「12(inputValue)」を入力後「+」を押した場合
                when(_calculateType.value) {
                    //23(totalValue)に12(inputValue)が足された値(35)が_totalState.valueに代入される
                    CalculateType.PLUS -> _totalState.value = totalValue.plus(inputValue)
                    CalculateType.MINUS -> _totalState.value = totalValue.minus(inputValue)
                    CalculateType.MULTIPLY -> _totalState.value = totalValue.times(inputValue)
                    CalculateType.DIVIDE -> _totalState.value = totalValue.div(inputValue)
                    else -> return
                }
            } else {
                // まだ演算子が選ばれていない場合、最初の入力値を合計に設定
                _totalState.value = inputValue
            }

            // 次の計算用に選択された演算子を更新する
            _calculateType.value = thisCalculateType
            _calculateTypeText.value = when(thisCalculateType) {
                CalculateType.PLUS -> "+"
                CalculateType.MINUS -> "-"
                CalculateType.MULTIPLY -> "×"
                CalculateType.DIVIDE -> "÷"
            }

            // 表示を更新して次の入力準備をする
            _inputTextState.value = formatNumber(_totalState.value!!)
            _inputNumberState.value = null
            _decimalMode.value = false
        }
    }

    // クリアボタン(C)が押されたとき、全てをリセット
    fun clearCalculator() {
        _inputNumberState.value = null
        _totalState.value = null
        _calculateType.value = null
        _calculateTypeText.value = ""
        _decimalMode.value = false
        _inputTextState.value = "0"
    }

    // イコールボタン(=)が押されたときの処理
    fun sumUpCalculate() {
        val inputValue = _inputNumberState.value
        val totalValue = _totalState.value

        if(inputValue != null && _calculateType.value != null) {
            if(totalValue != null) {

                // 最後に選択された演算子・これまでの合計数値(totalValue)・「=」を押す前に入力された数値(inputValue)を元に計算を行う
                when(_calculateType.value) {
                    CalculateType.PLUS -> _totalState.value = totalValue.plus(inputValue)
                    CalculateType.MINUS -> _totalState.value = totalValue.minus(inputValue)
                    CalculateType.MULTIPLY -> _totalState.value = totalValue.times(inputValue)
                    CalculateType.DIVIDE -> _totalState.value = totalValue.div(inputValue)
                    else -> return
                }
            }

            // 計算結果を画面に表示し、演算子をクリア
            _calculateType.value = null
            _inputNumberState.value = _totalState.value
            _inputTextState.value = formatNumber(_totalState.value!!)
            _calculateTypeText.value = ""
            _decimalMode.value = false
        }
    }

    // 数字を見やすくフォーマットする関数(例: 10.0 → 10)
    fun formatNumber(number: Double): String {
        val srtNumber = number.toString()
        return if(srtNumber.endsWith(".0")) {
            srtNumber.substring(0, srtNumber.length - 2)
        } else {
            return srtNumber
        }
    }

    // 小数点モードに切り替える処理
    fun changeToDecimal() {
        if(_inputNumberState.value != null) {
            // 現在の入力値がすでにある場合
            val inputNumberText = _inputNumberState.value.toString()
            _decimalMode.value = inputNumberText.endsWith(".0")
            if(_decimalMode.value) {
                val number = formatNumber(_inputNumberState.value!!)
                _inputTextState.value = number.plus(".")
            }
        } else {
            // 最初の入力がまだない場合、小数点から入力を始める
            _inputNumberState.value = 0.0
            val number = formatNumber(_inputNumberState.value!!)
            _inputTextState.value = number.plus(".")
            _decimalMode.value = true
        }
    }
}

こだわった部分

電卓アプリ1つとっても色々な機能を実装する必要があるのだなと感じましたが、中でも タイプに応じた計算処理(calculateNumber)はこのアプリの根幹の機能で特にこだわった部分でもあったので、少しだけ紹介いたします。

たとえば「120 × 3 + 140」を求めるとき

image-20241204011120596.png

のようになると思います。

これをスマホの電卓で入力していくと、3と140の間の「+」を押したタイミングでUIに「360」と表示されます(機種や電卓アプリによる差がなければ!)。 そのまま「140」と入力して「=」を押せば「500」と表示されます。

[iPhone標準の電卓アプリ]

calculator_video_3.gif

直感的にこのような操作ができますが、実はこの「+」を押したときには下記のような処理が行われています。

・これまでの数値(120)と最後に入力された数字(3)を取得 ・1個前に押された計算タイプでそれらを計算処理 ・次の計算で足し算をするよう設定

このように「+」を押したときに「=」のような処理をしつつ、次の計算の準備もしています。 これは1つの式(「=」を押すまで)に、2個以上の四則演算ボタン(+/ – /÷ / ×)が登場する式では2個目のボタン以降起きる処理になり今回はその機能を実際に再現してみました。

「+」を押したときに実際に起きている処理

今回作成したアプリで 120 × 3 + 140の「+」を押した際に起きていることを解説します。

  1. UIで+を押した時(CalculationHomeScreen.kt)
ContentButton(
        modifier = Modifier.weight(1f),
        onClick = { viewModel.plusNumber() },
        containerColor = Color(calculateColor),
        text = "+"
)

ContentButtonのonClickに指定した「viewModel.plusNumber()」が呼び出されます。

  1. ViewModelのplusNumber関数(CalculatorViewModel.kt)
fun plusNumber() { 
        calculateNumber(CalculateType.PLUS)
}

のように CalculateType.PLUSを渡してcalculateNumber関数が呼び出されます。

  1. ViewModelのcalculateNumber関数(CalculatorViewModel.kt) まず、計算処理に使用する数値が変数に代入されます。
val inputValue = _inputNumberState.value //直前に入力した数字「3」
val totalValue = _totalState.value //120

続いて以前に_calculateType.valueに保持していた「×(3を入力する前に保持 )」があるので、whenの条件で指定されているCalculateType.MULTIPLYの処理が実行されます。

if(_calculateType.value != null && totalValue != null) {
    when(_calculateType.value) {
        CalculateType.PLUS -> _totalState.value = totalValue.plus(inputValue)
        CalculateType.MINUS -> _totalState.value = totalValue.minus(inputValue)
        //👇の処理が実行される
        CalculateType.MULTIPLY -> _totalState.value = totalValue.times(inputValue)
        CalculateType.DIVIDE -> _totalState.value = totalValue.div(inputValue)
        else -> return
    }
} else {
    _totalState.value = inputValue
}

そのため_totalState.valueには「totalValue.times(inputValue)、つまり120 × 3」の実行結果の値(360)が代入されます。

次いで次の計算で使用される計算タイプ(関数実行時に渡されたCalculateType.PLUS)が保持され、

 _calculateType.value = thisCalculateType

UIに表示する計算タイプも「+」に更新されます。

_calculateTypeText.value = when(thisCalculateType) {
    CalculateType.PLUS -> "+"
    CalculateType.MINUS -> "-"
    CalculateType.MULTIPLY -> "×"
    CalculateType.DIVIDE -> "÷"
}

また、今回計算処理がされ更新された_totalState.value(360)がUIに表示しているStateFlow変数に代入され、

_inputTextState.value = formatNumber(_totalState.value!!)

それに応じてUIでは360が表示されるという流れとなっています。

開発してみて

細かい実装内容などはソースコードのコメントを読んでいただきたいのですが、どれも基礎学習で習得した技術でUIや機能ができていることがわかります(特にStateFlowやif文は重要な役割を担っています)。

中でも

・数字入力中の2桁以上の整数の処理(2桁以上は数字を後ろにつける必要がある) ・小数点以下のモード時の処理 ・タイプに応じた計算処理

などの機能面では「習得した基礎知識をどのように扱うか」というテクニックが必要でした。 これは知識をインプットするだけでは得られない、 自作アプリ開発というアウトプット実践だからこそ得られた経験だと思います。

登竜門というだけあり、総じて電卓アプリは基礎をしっかり活用して開発することになったため、1つ目のアプリとしてはかなり良いアウトプットになりました。

その後

電卓アプリを作成した後も引き続き個人でアプリ開発を続けていき、Googleトレーニングコースでは学習しなかった内容も、自作アプリ開発を通して学習していきました。

Room(端末内のデータベースを操作できるライブラリ) Retrofit(APIで情報を取得できるライブラリ) ・MVVMアーキテクチャ ・DI(依存関係インジェクション) など

上記の技術・知識は実際のアプリ開発においても重要な内容であるものの、基礎知識の学習だけではインプットする機会がありませんでしたが、自作アプリの開発を進めていき より良いアプリを開発する過程で学習を進めていくことができました!

自作アプリ開発の魅力

自作アプリ開発は色々と大変な場面も多く(1つのエラーに何時間も詰まった時なども何度かありました💦)、わからないことはAIに助けてもらいながらコツコツと進めていきましたが、

なんといっても「自分が思い描いているアプリに近づいていく時・思い描いたアプリに出来上がった時」のあの達成感は忘れられません。

自作アプリ開発は「学習」という点で アウトプット/インプットの量がかなり多く学習効率の良い勉強方法ですが、「継続性」という点でも自分のペースで進めることができ、達成感がより感じられるため非常に良い学習方法であること間違いなしです。

開発したアプリをGitHubで公開&ポートフォリオを作成

上記のような流れで自作アプリをいくつか開発していきましたが、転職活動を始めるためには今まで作ってきたアプリを誰でも見れるようにして実力を知ってもらう必要があります。

せっかく開発したものも目に見える形でアピールしなければ宝の持ち腐れです。

そのため

  • Githubでソースコードを公開
  • ポートフォリオを作成して見た目や機能の紹介 という2つのアプローチで目に見える形にしました。

GitHub

GitHubとはソースコードを保存・共有・管理などができるウェブサービス です。 自分の開発したアプリのソースコードをインターネット上に保存して誰でも見えるようにすることができます。

Githubへのソースコードの公開方法は調べればすぐに出てきますので、これから転職活動を始めていく方でまだソースコードを公開していない方は今すぐGithubアカウントを作成しソースコードを誰でも見れるようにしておきましょう!

ポートフォリオ

ポートフォリオは自分が今まで開発してきたアプリ・その詳細などを画像や動画とともに紹介することで採用担当の方に自分を効率よくアピールすることができます。 そういった点でGitHubへのソースコード公開と同じかそれ以上に重要なものです。

色々と自作アプリを開発して「さあ転職活動を始めよう!」となったタイミングで、「ポートフォリオも作成しなきゃなのか、、」となった方もいらっしゃるかもしれませんが、

ここまで進んできたからこそ「ポートフォリオなしで転職活動を始める」のは非常にもったいないです。

私は学習も兼ねてiOS / Androidの両方を開発(クロスプラットフォーム開発)することができるFlutterでポートフォリオを作成しました。 FlutterはWebアプリも開発することができるため、ポートフォリオをWebアプリとしてFirebase Hostingというサービスを使用して公開しました。

モチベーションを持続させた方法

学習内容や流れを説明していきましたが、このように自分のプライベートの時間を学習時間に変えるには、モチベーションが続かないという方も多いかと思います。

かくいう私も学習始めたての時はモチベーションが下がり、「今日は勉強はいいや」となりPCを開かない日もありました。 よくそういう時には「まずPCを開いて机に向かって5分やってみると良い」という小手先の解決方法などがありますが、そういった表面的な解決案はその日効果があっても持続的にモチベーションを上げてくれるわけではなく、次の日にまた同じ問題に直面すると思います。

ではどうやってモチベーションを持続させたのかというと 自分の中で強固な土台を作りました。

棒で例えると、細い棒は少しの風で倒れてしまいます。 ですが底辺を太くすると少しの風では倒れなくなります。

モチベーションを持続させる力は「棒を◯回立て直せる力」になりますが、そもそも倒れる回数を少なくすればいい(モチベーションという棒の底を広くすればいい)と思いました。

そこで、自分の中で「そもそもなんでアプリ開発がしたいのか」という動機・目的を見つめ直す時間をとり、自分の中でアプリ開発をすることで なぜ・何をしたいかをじっくりゆっくり考え書き起こしました。

本当に色々な動機・目的が出てきて1つに絞ることはできませんでしたが、その全てが自分のアプリ開発をする上での動機・目的と納得することができ見直せたことで、モチベーションという棒が倒れることがかなり少なくなりました(大前提で「開発が楽しかった」ということもありますが、楽しいも動機の大きな1つで良いと思います)。

モチベーションを保つのに苦労している方はぜひ一度自分と向き合って見てください。 目的・動機の数は1つでも100個でも大丈夫だと思います。 大事になのはそれが 本当の動機・目的と自分で納得して理解することです。 それだけで自然と土台は大きくなっているはずですので!

まとめ

私はこれまで紹介したような学習方法・流れでレコチョクに Androidアプリエンジニアとして転職することができました!

とは言っても、アプリエンジニアとしてまだまだスタートを切ったばかりの新米ですので、 実務で得た知見の吸収や個人学習で技術を引き続き伸ばしていきつつ、技術だけでなく「ユーザー目線でより良いアプリ」を考え実行できるアプリエンジニアを日々目指していきます!

この記事の目的でもお伝えしましたが、今現在エンジニア就職/転職を目指して日々奮闘している方々の背中を少しでも押せて、学習方法なども少しでも参考になれれば幸いです!

明日のレコチョク Advent Calendar 2024は11日目『【Kotlin】Jetpack Composeで無限スクロール風とスワイプ検知』です。

瀬川 亮

目次