じゃがいも畑

じゃがいも畑

開発ネタの記録

【KMP】既存のAndroid, iOSアプリにKotlin Multiplatformのsharedモジュールを作成する

whitedog0215.hatenablog.jp

こちらの記事の続きになります

新規開発だったらこの手順でいいと思いますが、世の中そんなに新規開発することってないですよね

開発当初にAndroidiOSアプリを別々に作成してそれぞれメンテしているようなシチュエーションもよくあると思います

今回はそんな感じの場合を想定して、Kotlin Multiplatformでsharedモジュールを作成して両OSから呼び出せるAPIを作成していきたいと思います

サンプルプロジェクト

こちらにサンプルプロジェクトを作成しました

github.com

トップ階層がandroidのプロジェクトとなっており、ここに含まれるSharedExample以下がiOSのプロジェクトという構成になっています

それぞれのアプリを実行すると画像のようになります

+ボタンを押すと数字がインクリメントされるだけのシンプルなアプリになっています

このサンプルアプリにsharedモジュールを追加してボタンを押したら数値が2倍になるという動きに修正していきましょう

早速やっていきたいと思います

Android Studio設定の変更

Android Studioからsharedモジュールを作成していくことになるのですが、デフォルトの設定だとKotlin Multiplatformのモジュールテンプレートが表示されません

設定画面を開き、Advanced Settingsから「Enable experimental Multiplatform IDE features」を探してチェックを入れ、Android Studioを再起動しましょう(なんでこれがデフォルトオフなんだ・・・)

sharedモジュールを作成する

右クリックメニューを開いてNew>Moduleを選択してモジュールを追加していきます

Androidの設定を変更したおかげでKotlin Multiplatform Shared Moduleのテンプレートが表示されているので、選択して必要な情報を入力します

  • Module Name:shared
  • iOS framework distribution:Regular framework
  • そのほかは適当に

これでプロジェクトにsharedモジュールが追加されました。この段階ではGradle syncがエラーになると思います

ビルドエラーの解決

今出ているエラーを解消してモジュールを利用できるようにします

build.gradle.ktsのエラー解消

追加したsharedモジュールにはbuild.gradle.ktsが含まれていますが、pluginsブロックに記載された「kotlinMultiplatform」「androidLibrary」の参照が解決できていません

これを解消するためにgradle/libs.versions.tomlを修正します。以下の2行を追加します

androidLibrary = { id = "com.android.library", version.ref = "agp" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }

続いてこちらの記事にあるとおり、root階層のbuild.gradle.ktsを修正します

以下の2行をpluginsブロックの中に追加します

    alias(libs.plugins.kotlinMultiplatform) apply false
    alias(libs.plugins.androidLibrary) apply false

最後にapp/build.gradle.ktsにsharedモジュールへの依存を追加します

以下の1行をdependenciesブロックの中に追加します

    implementation(project(":shared"))

ここまでできたらGradleのsyncを実行します。そしたらなんかエラーが出ました

Unresolved reference. None of the following candidates is applicable because of receiver type mismatch:

想定外だったので原因を色々調べていたらlibs.versions.tomlのkotlinバージョンが1.9.0指定となっていたのが問題だったようです

なのでlibs.versions.tomlのversionsブロックに記載されているkotlinの値を以下に修正します

kotlin = "2.0.0"

これで再度syncを実行。すると今度はsourceSetsのcommonTest.dependenciesで同じエラー

これはlibs.kotlin.testが存在しないことが原因のため今回は削除で対応します

修正して再度sync

警告は残ってますがなんとかビルド成功までできました

APIの作成

sharedモジュールに数値を2倍にするAPIを作成していきます

両方のプラットフォームから呼び出せるAPIにするため、commonMain内にCalculatorクラスを追加します

実装はこんな感じ。追加してビルドしましょう

package com.jagapoko.shared

class Calculator {
    fun double(number: Int): Int {
        return number * 2
    }
}

ここで再びエラーが出ました。Compose Compiler Gradle pluginが不足している場合に出るエラーのようです

Starting in Kotlin 2.0, the Compose Compiler Gradle plugin is required

なのでgradle/libs.versions.tomlとshared/build.gradle.ktsを修正してエラーを解消します

  • libs.versions.toml
[versions]
compose-runtime = "1.6.11"

[plugins]
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
compose = { id = "org.jetbrains.compose", version.ref = "compose-runtime" }
  • build.gradle.kts
plugins {
    alias(libs.plugins.compose)
    alias(libs.plugins.compose.compiler)
}

    sourceSets {
        commonMain.dependencies {
            //put your multiplatform dependencies here
            implementation(compose.runtime)
        }
    }

これでAPIの利用準備ができました

APIの利用

Android側からの利用

MainActivityのCounterScreenを以下のように修正します

fun CounterScreen(modifier: Modifier = Modifier) {
    // カウントの初期値を1に修正
    var count by remember { mutableIntStateOf(1) }

    Column(
        modifier = modifier.run {
            fillMaxSize()
                .padding(16.dp)
        },
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = "$count", style = MaterialTheme.typography.headlineMedium)

        Spacer(modifier = Modifier.height(16.dp))

        // 以下のボタンクリック時にCalculator.doubleを呼び出すように修正
        Button(onClick = { count = Calculator().double(count) }) {
            Text(text = "+")
        }
    }
}

これでビルドしたらまたまたエラー

Starting in Kotlin 2.0, the Compose Compiler Gradle plugin is required

なので、app側にもCompose Compilerの追加が必要のようです。app/build.gradle.ktsを先ほどと同じように修正しましょう

  • app/build.gradle.kts
plugins {
    alias(libs.plugins.compose.compiler)
}

これでビルドが通ればsharedモジュールの呼び出し実装も完了です

こんな感じで数値が2倍にされていくようになります

iOS側からの利用

iOS側からも呼び出してみましょう。XCodeでSharedExampleプロジェクトを開きます

プロジェクトを選択して「Build Phases」を選択し、+ボタンを押して「New Run Script Phase」を選択します

作成したRun Scriptを選択し、以下のスクリプトを追加します

cd "$SRCROOT/.."
./gradlew :shared:embedAndSignAppleFrameworkForXcode

作成したRun ScriptをCompile Sourcesの上に配置します

続いて「Build Settings」を開き、「User Script Sandboxing」をNoに設定します

これでsharedを利用するためのプロジェクト設定は完了です。ビルドしてみましょう

ビルドログを確認して追加したRun Scriptが動作しており、エラーが出てなければOKです

ContentViewを以下のように修正します

import SwiftUI
import shared       // sharedのimportを追加

struct ContentView: View {
    
    @State private var count: Int = 1   // カウントの初期値を1に修正
    
    var body: some View {
        VStack {
            // 現在のカウントを表示
            Text("\(count)")
                .font(.title)
                .padding()

            // カウントをインクリメントするボタン
            Button(action: {
                // Calculatorの呼び出しを追加(Int32, Intのキャストが必要)
                count = Int(Calculator().double(number: Int32(count)))
            }) {
                Text("+")
                    .font(.headline)
                    .padding()
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(8)
            }
        }
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(Color(.systemBackground))
    }
}

これでビルドエラーがなければ実行してみましょう

こんな感じで同じように数値が2倍になっていれば成功です

最後に

こんな感じですでに存在するアプリ向けにKotlin Multiplatformを導入するための手順を紹介しました

追加直後はエラーと格闘することになりますが、エラーが解消してしまえば気軽に共通処理をKotlinで実装できるのではないかと思います

追加前後での差分はこんな感じでした。何かの参考になればと思います

github.com

【KMP】Kotlin Multiplatformでクロスプラットフォームアプリを作る(プロジェクト新規作成)

以前こちらの記事で書いた通り、プロジェクト作成方法をまとめておこうと思います

whitedog0215.hatenablog.jp

開発環境

記事作成時点での開発環境は以下になります

使用しているOSはMacです

KMPプロジェクトの作成

www.jetbrains.com

公式サイトのこちらの手順に沿って進めていきます

Android Studioでの開発環境はすでに構築済みの前提になります

Kotlin Multiplatform pluginの導入

クロスプラットフォーム用のプロジェクトを作成するために、Kotlin Multiplatform pluginを導入します

Android Studioを立ち上げた時に出るウィザードでPluginsを選択し、検索窓に「Kotlin Multiplatform」と入力

画像の通りKotlin Multiplatformのプラグインが出てくるので「Install」を押します

インストールが完了すると「Restart IDE」のボタンが出てくるので、ボタンを押してAndroid Studioを再起動します

プロジェクトの作成

再起動が完了したらウィザード画面で「New Project」を選択

メニューから「Kotlin Multiplatform App」を選択してNextを押します

次の画面ではプロジェクト情報を入力

Build configuration languageは特に理由がなければKotlin DSLのままにしておきましょう

さらに次の画面では、AndroidiOS・共有モジュールの名前を設定します

当然ですがそれぞれの名前は被らないようにつけましょう。あとでバグります

iOS framework distributionは特に理由がなければRegular frameworkを選びましょう

新規作成のクロスプラットフォームアプリであればわざわざCocoaPodsを導入しなくてもよいのではと思います

すべて入力できたら「Finish」を押してプロジェクト作成完了です

Androidアプリの実行

プロジェクトを作成すると必要なパッケージのダウンロードなどが始まります(最初は結構時間がかかると思います)

全て完了して、シミュレータor実機の実行環境が整っていれば「Run」ボタンを押しましょう

画像のように「Hello, Android XXX!」と出ていれば成功です

iOSアプリの実行

Android StudioからiOSアプリの実行もできますが、ここではXCodeを使ってビルド&実行していきます

プロジェクトフォルダを開き、iOSモジュールのフォルダ(ここではsampleapp-ios)のsampleapp-ios.xcodeprojを開きます

XCodeが開いたら早速実行ボタンを押しましょう

画像のように「Hello, Android XXX!」と出ていれば成功です

おわりに

ここまでKotlin Multiplatformのプロジェクトを作成して、クロスプラットフォームアプリを新規作成する手順をまとめました

今回は新規作成についてまとめましたが、既存のAndroidiOSプロジェクトにKotlin Multiplatformを導入する方法もそのうちまとめたいと思います

【KMP】Kotlin MultiplatformでJSONファイルをリソースに追加して読み込む

最近はKotlin Multiplatformをよく触るようになりました

つまづくことが多い割に日本語の記事も少ないので、調べたことをまとめておこうと思います(みんなどこで調べてるんだろう)

ソースコード

作成したサンプルは以下のGitHubリポジトリに上げています

github.com

開発環境

作成時点での開発環境は以下になります

プロジェクトの設定

www.jetbrains.com

こちらの公式サイトを参考にプロジェクト作成後からリソースファイルの読み込みまでの手順を記載していきます

プロジェクト作成方法は別途まとめておきたいと思います

プロジェクト作成後のこの状態から開始

必要な依存関係の追加

モジュールでリソースを扱えるようにするためにcompose.components.resourcesを利用するのですが、そのために必要な依存関係を追加していきます

ここには修正内容のみ記載するので全体を見たい方はリポジトリの方をご覧ください

gradle/libs.versions.toml

依存関係のバージョン管理にはlibs.versions.tomlを利用しているのでこちらに以下の情報を追加します

[versions]
compose-runtime = "1.6.11"
[plugins]
compose = { id = "org.jetbrains.compose", version.ref = "compose-runtime" }

shared/src/build.gradle.kts

自分はsharedモジュールの方でリソースを使いたかったのでこちらのbuild.gradle.ktsに依存関係を追加しています

composeとcompose-compilerをpluginsブロックに追加します

plugins {
    alias(libs.plugins.compose)
    alias(libs.plugins.compose.compiler)
}

commonMain.dependenciesに以下を追加します

    sourceSets {
        commonMain.dependencies {
            implementation(compose.runtime)
            implementation(compose.components.resources)
        }
    }

これで必要な依存関係の追加は完了です

composeResourcesディレクトリの作成

www.jetbrains.com

こちらに記載がある通り、リソースを利用するためにはcomposeResourcesディレクトリを利用する必要があります

commonMain以下に作成することでプラットフォーム共通のリソースとして使用することができます

以下の画像のようにディレクトリを作成しましょう

ここで各ディレクトリには以下の規則で利用する必要があるようです

  • drawable: 画像
  • font: フォント
  • values: 文字列
  • files: その他のファイル

今回はJSONファイルをリソースとして利用したいのでfilesディレクトリに配置します

fruits_data.jsonをfilesディレクトリに配置

ファイルを配置したらビルドしてみましょう build/generated/compose/resourceGenerator/kotlin/commonResClass以下に自動生成されたResがあれば多分準備完了です

sharedモジュールにJsonを読み込むAPIを追加する

今回は元々存在するGreetingクラスのgreetをリソースを読み込むためのAPIに変更していきます

    @OptIn(ExperimentalResourceApi::class)
    suspend fun greet(): String {
        val bytes = Res.readBytes("files/fruits_data.json")
        return bytes.decodeToString()
    }

ここでのポイントは以下の通りです。まだExperimentalなのは注意が必要ですね

  • ResのreadBytesでファイルを読み込める
  • readBytesがExperimentalResourceApiなので@OptIn(ExperimentalApi::class)をつける
  • readBytesを扱うAPIはsuspendにする
  • リソースはfilesディレクトリからのパスを指定する
  • byte arrayが返るのでStringにデコードする

動かしてみる

androidApp, iosApp側のgreetを呼ぶソースをsuspend対応のものに修正して動かしてみます

どちらもちゃんと読み込めていますね

【C#】一番手っ取り早いSystem.Text.Jsonのデシリアライズ

年に1回ぐらいJsonを扱うんだけど、毎回忘れるので手順を残します
Jsonのデシリアライズでつまずきたくない人向け

※サンプル作成にあたり、以下のWebサイト様&WebAPIを使用させていただきました
ありがとうございました

blog.tsukumijima.net

シリアライズするJson

{ "publicTime": "2021-01-09T17:00:00+09:00", "publicTime_format": "2021/01/09 17:00:00", "title": "東京都 東京 の天気", "link": "https://www.jma.go.jp/jp/yoho/319.html", "description": { "text": "東京都では、強風や高波、急な強い雨、落雷、空気の乾燥した状態が続くため、火の取り扱い、霜に対する農作物の管理に注意してください。\n\n日本付近は、強い冬型の気圧配置となっています。\n\n東京地方は、晴れや曇りとなっています。\n\n9日は、強い冬型の気圧配置が続きますが、気圧の谷や湿った空気の影響を受けるため、晴れ時々曇りとなるでしょう。伊豆諸島では、雷を伴い雪や雨の降る所がある見込みです。\n\n10日は、冬型の気圧配置は次第に緩み、高気圧に覆われますが、気圧の谷や湿った空気の影響を受けるため、晴れで朝晩は曇りとなる見込みです。伊豆諸島では、雷を伴い雪や雨の降る所がある見込みです。\n\n<天気変化等の留意点>\n伊豆諸島南部では、9日は、曇りで雷を伴い雪や雨の降る所があるでしょう。\n10日は、曇りで雷を伴い雪や雨の降る所がある見込みです。\n(雨の予想)\n9日18時から10日18時までに予想される24時間降水量は、多い所で、伊豆諸島南部20ミリの見込みです。\n\n【関東甲信地方】\n関東甲信地方は、晴れや曇りで、雪の降っている所があります。\n\n9日は、強い冬型の気圧配置が続くため、晴れや曇りで、長野県や関東地方北部では雪の降る所があるでしょう。伊豆諸島では、雷を伴って雪や雨の降る所がある見込みです。\n\n10日は、冬型の気圧配置は次第に緩み、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れや曇りで、長野県や関東地方北部では雪の降る所があるでしょう。伊豆諸島では、雷を伴って雪や雨の降る所がある見込みです。\n\n関東地方と伊豆諸島の海上では、9日から10日にかけて、うねりを伴いしけるでしょう。船舶は高波に注意してください。", "publicTime": "2021-01-09T16:35:00+09:00", "publicTime_format": "2021/01/09 16:35:00" }, "forecasts": [ { "date": "2021-01-09", "dateLabel": "今日", "telop": "晴時々曇", "temperature": { "min": null, "max": null }, "chanceOfRain": { "00-06": "--%", "06-12": "--%", "12-18": "--%", "18-24": "10%", "T00_06": "--%", "T06_12": "--%", "T12_18": "--%", "T18_24": "10%" }, "image": { "title": "晴時々曇", "url": "https://weather.tsukumijima.net/icon/2.gif", "width": 50, "height": 31 } }, { "date": "2021-01-10", "dateLabel": "明日", "telop": "晴時々曇", "temperature": { "min": { "celsius": "-2", "fahrenheit": "28.4" }, "max": { "celsius": "6", "fahrenheit": "42.8" } }, "chanceOfRain": { "00-06": "10%", "06-12": "0%", "12-18": "0%", "18-24": "10%", "T00_06": "10%", "T06_12": "0%", "T12_18": "0%", "T18_24": "10%" }, "image": { "title": "晴時々曇", "url": "https://weather.tsukumijima.net/icon/2.gif", "width": 50, "height": 31 } }, { "date": "2021-01-11", "dateLabel": "明後日", "telop": "曇時々晴", "temperature": { "min": null, "max": null }, "chanceOfRain": { "00-06": "--%", "06-12": "--%", "12-18": "--%", "18-24": "--%", "T00_06": "--%", "T06_12": "--%", "T12_18": "--%", "T18_24": "--%" }, "image": { "title": "曇時々晴", "url": "https://weather.tsukumijima.net/icon/9.gif", "width": 50, "height": 31 } } ], "location": { "city": "東京", "area": "関東", "prefecture": "東京都" }, "copyright": { "link": "https://weather.tsukumijima.net/", "title": "(C) 天気予報 APIlivedoor 天気互換)", "image": { "width": 120, "height": 120, "link": "https://weather.tsukumijima.net/", "url": "https://weather.tsukumijima.net/logo.png", "title": "天気予報 APIlivedoor 天気互換)" }, "provider": [ { "link": "https://www.jma.go.jp/jma/", "name": "気象庁 Japan Meteorological Agency", "note": "気象庁 HP にて配信されている天気予報を json データへ編集しています。" } ] } }

NugetパッケージにSystem.Text.Jsonを追加

「text.json」とかで検索をかけてインストールを押す f:id:whitedog0215:20210109233552p:plain

Jsonをデシリアライズするためのクラスを作る

とりあえず、Tenkiとか名前を付けてファイルを作成

f:id:whitedog0215:20210110000656p:plain

Jsonデータをコピーして、Visual Studioの「編集」-> 「形式を選択して貼り付け」-> 「Jsonをクラスとして貼り付ける」を選択

f:id:whitedog0215:20210110000740p:plain

こんな感じでクラスが自動生成される。超便利。
クラス名がRootObjectになっているので修正しておく
f:id:whitedog0215:20210110001128p:plain

「形式を選択して貼り付け」が無い場合

Visual Studio Installerから「ASP.NETとWeb開発」のコンポーネントを追加する

f:id:whitedog0215:20210110000855p:plain

シリアライズする

以下のコードでデシリアライズを実行する。

var tenki = JsonSerializer.Deserialize<Tenki>(jsonStr);

f:id:whitedog0215:20210110002511p:plain

ちゃんとできてますね。

【C#】異なるn個のものからr個選ぶ組み合わせを列挙する(Combination)

作ったもの

入力のリストと選ぶ個数を渡すと組み合わせを列挙してくれるCombinationクラスを作りました
ほんとはyield returnで作って拡張メソッドにしたかったんですが、生成速度が遅くなる(自分の実力不足)のと動きが追っかけにくいのでこの形にしました

再帰とyield return組み合わさると難しすぎんか・・・

ソースコード

static class Combination
{
    private static List<List<int>> _comb;

    public static List<List<int>> Generate(int n, int r, bool dupulication)
    {
        _comb = new List<List<int>>();
        CalcCombination(new List<int>(), n, r, dupulication);
        return _comb;
    }

    private static void CalcCombination(List<int> list, int n, int r, bool dupulication)
    {
        if (list.Count == r)
        {
            _comb.Add(new List<int>(list));
            return;
        }

        var index = 0;
        if (dupulication)
        {
            index = list.Any() ? list.Last() : 0;
        }
        else
        {
            index = list.Any() ? list.Last() + 1 : 0;
        }
        for (int i = index; i < n; i++)
        {
            list.Add(i);
            CalcCombination(list, n, r, dupulication);
            list.Remove(i);
        }
    }
}

使い方

Generateメソッドは異なるn個のものからr個取ってくる組み合わせを列挙した結果を返します
dupulicationには重複あり、無しを指定します

異なる5個のものから3個取ってくる組み合わせを列挙すると

重複ありの場合

Combination.Generate(5, 3, true)

f:id:whitedog0215:20200825225617p:plain

重複無しの場合

Combination.Generate(5, 3, false)

f:id:whitedog0215:20200825225356p:plain

となります
これを配列やリストの添え字に使ってやればなんでも組み合わせ列挙できますね

解説

組み合わせの列挙

CalcCombinationを再帰呼び出ししてlistに要素を追加していきます
listの要素数がr個になったら_combにリストのコピーを追加します
コピーした後は最後の要素を取り除いてから次の要素をlistに追加します

f:id:whitedog0215:20200825235129p:plain

重複ありの場合

列挙の処理の中で{0, 1, 2}, {2, 0, 1}, {1, 2, 0}のような重複を避けたいので、必ず前の要素<= 後ろの要素になるようにします
listに最後に追加した要素からforループが始まるようにして前の要素<= 後ろの要素を実現しています

f:id:whitedog0215:20200826000527p:plain f:id:whitedog0215:20200826000719p:plain

重複無しの場合

必ず前の要素 < 後ろの要素であればよいので、listに最後に追加した要素+1からforループが始まるようにする
f:id:whitedog0215:20200826000935p:plain f:id:whitedog0215:20200826001048p:plain

木での表現

上記の内容を木にするとこんな感じ

重複ありの木

f:id:whitedog0215:20200827001853p:plain

重複無しの木

f:id:whitedog0215:20200827001915p:plain

速度

n=55, r=5, 重複ありの組み合わせ(約5000000通り)を列挙するのに大体1500msでした

C# doubleをintにキャストするときはちゃんとMath.Roundする

タイトルそのままの記事です

docs.microsoft.com

問題のコード

1.01から10.0まで、それぞれに100を掛けた値を整数で出力するプログラム

        double value = 1.01;
        while (value < 10.0)
        {
            var result = value * 100;
            Console.WriteLine($"{result} --- Cast --->{(int)result}");
            value += 0.01;
        }

出力結果は101~1000になってほしいが、これを実行するとこんな感じになる

f:id:whitedog0215:20200818220143p:plain
999.9999999999832はキャストしたら1000になってほしいが小数点以下が切り捨てられて999にされている

対策

Math.Roundを使いましょう

        double value = 1.01;
        while (value < 10.0)
        {
            var result = Math.Round(value * 100);
            Console.WriteLine($"{result} --- Cast --->{(int)result}");
            value += 0.01;
        }

f:id:whitedog0215:20200818220824p:plain
これで良し
初歩的なことだけどたまに忘れるとハマるやつ

C# 2次元リストのコピー

C# でリストAの中身をリストBにコピーしてリストBで値の変更などをしたい場合、以下のようにすれば値渡しでコピーができる

        var listA = new List<int> { 1, 2, 3 };
        var listB = new List<int>(listA);  // 値渡し

        // listB = listAは参照渡し

        listB[0] = 3;
        listB[1] = 3;
        listB[2] = 3;

        Console.WriteLine(string.Join(" ", listA));     // 1 2 3
        Console.WriteLine(string.Join(" ", listB));     // 3 3 3

けど2次元リストで同じ方法を使うと入れ子になったリストが参照渡しになってしまう

        var listA = new List<List<int>> {
            new List<int>{1, 2, 3},
            new List<int>{1, 2, 3},
        };

        // 外のリストは値渡し、入れ子のリストは参照渡し
        var listB = new List<List<int>>(listA);     

        listB[1][0] = 3;
        listB[1][1] = 3;
        listB[1][2] = 3;

        foreach (var item in listA) Console.WriteLine(string.Join(" ", item));  
        foreach (var item in listB) Console.WriteLine(string.Join(" ", item));  

       // 出力結果
       // 1 2 3 3 3 3
       // 1 2 3 3 3 3

listAは1 2 3 1 2 3のままでいてほしいのに1 2 3 3 3 3になってしまっている

しょうがないので2次元リストをDeepCopyする拡張メソッドを作る

namespace Extension
{
    public static class Extensions
    {
        public static List<List<T>> DeepCopy<T>(this List<List<T>> source)
        {
            var copyList = new List<List<T>>();
            foreach (var item in source)
            {
                copyList.Add(new List<T>(item));
            }
            return copyList;
        }
    }
}

これでOK

        var listA = new List<List<int>> {
            new List<int>{1, 2, 3},
            new List<int>{1, 2, 3},
        };

        var listB = listA.DeepCopy();

        listB[1][0] = 3;
        listB[1][1] = 3;
        listB[1][2] = 3;

        foreach (var item in listA) Console.WriteLine(string.Join(" ", item));
        foreach (var item in listB) Console.WriteLine(string.Join(" ", item));

       // 出力結果
       // 1 2 3 1 2 3
       // 1 2 3 3 3 3