2026/04/15

SwiftUI で Image をカスタム形状にクリッピング - 5つの実装パターン

はじめに

SwiftUI で Image を表示する際、見た目を工夫したいことはよくある。丸いプロフィール画像、角丸カード、複雑なロゴ形状...こうした場合に活躍するのが clipShape modifier だ。

この記事では、clipShape を使った 5 つの実装パターン、mask との使い分け、iOS 18 での新機能、実装時の注意点まで、実践的に解説する。

clipShape とは何か

基本的な役割

clipShape は、View を指定した Shape の形状でクリッピングする modifier。Image だけでなく、あらゆる View に適用できる。iOS 13.0 以降で使用可能。

使い方はシンプル。View に .clipShape() を追加して、括弧の中に使いたい Shape を指定するだけ。

clipShape と mask:何が違うのか

同じクリッピングに見えるが、内部動作は異なる。clipShape は Shape のアウトラインを使ったシンプルなクリッピング。mask はピクセルレベルで自由に制御できる。

使い分けの指針は明確だ。形状がシンプルなら clipShape。複雑な形状やグラデーション、半透明を含むなら mask。

項目 clipShape mask
用途 Shape ベースのクリップ ピクセルレベル制御
パフォーマンス 高速(推奨) 遅い
扱える形状 Circle, Rectangle 等 任意(グラデーション等)
一般的な選択 9 割はこちら 特殊な要件時のみ

よく使う 5 つのパターン

1. 円形クリップ(プロフィール画像)

最も一般的。プロフィール画像、アバター、丸いボタン...こうした場面では Circle を使う。

import SwiftUI

struct ProfileView: View {
    var body: some View {
        Image("profileImage")
            .resizable()
            .scaledToFill()
            .frame(width: 120, height: 120)
            .clipShape(Circle())
            .overlay(
                Circle()
                    .stroke(Color.blue, lineWidth: 2)
            )
    }
}

ポイント:resizable() と scaledToFill() でアスペクト比を保ったまま枠を埋める。overlay で枠線を追加すると、さらに見栄えが良くなる。

2. 角丸四角形(カード、サムネイル)

RoundedRectangle でコーナーを丸くする。cornerRadius で丸さを調整。カード、サムネイル、バナーに使う。

struct CardView: View {
    var body: some View {
        Image("thumbnail")
            .resizable()
            .scaledToFill()
            .frame(height: 200)
            .clipShape(RoundedRectangle(cornerRadius: 12))
            .shadow(radius: 4)
    }
}

cornerRadius は 8-16 が標準的。大きすぎるとピルになってしまう。shadow() を組み合わせると、カードとして浮き上がって見える。

3. 楕円形(ロゴ、複雑な画像)

横長や縦長の楕円が必要な場合、Ellipse を使う。frame のアスペクト比に応じて形状が決まる。

struct LogoView: View {
    var body: some View {
        Image("logo")
            .resizable()
            .scaledToFill()
            .frame(width: 200, height: 100)
            .clipShape(Ellipse())
    }
}

4. カスタム形状(Path でオリジナル図形)

五角形、三角形、ハート...こうした複雑な形状は、Shape プロトコルに準拠したカスタムクラスで定義する。

struct StarClipView: View {
    var body: some View {
        Image("icon")
            .resizable()
            .scaledToFill()
            .frame(width: 150, height: 150)
            .clipShape(StarShape())
    }
}

// 五角形を定義
struct StarShape: Shape {
    func path(in rect: CGRect) -> Path {
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let radius = min(rect.width, rect.height) / 2

        var path = Path()

        for i in 0..<5 {
            let angle = CGFloat(i) * 0.8 * .pi - .pi / 2
            let x = center.x + radius * cos(angle)
            let y = center.y + radius * sin(angle)

            if i == 0 {
                path.move(to: CGPoint(x: x, y: y))
            } else {
                path.addLine(to: CGPoint(x: x, y: y))
            }
        }

        path.closeSubpath()
        return path
    }
}

Shape プロトコルには path(in:) メソッドを実装する。CGPath を使ってフリーハンドで図形を描く。

5. アニメーション付きクリップ

状態に応じて形状を変える。@State で isExpanded を持って、tap で切り替える。形状を動的に変える場合は AnyShape でラップする。

struct ExpandableClipView: View {
    @State private var isExpanded = false

    var body: some View {
        VStack {
            Image("icon")
                .resizable()
                .scaledToFill()
                .frame(
                    width: isExpanded ? 250 : 150,
                    height: isExpanded ? 250 : 150
                )
                .clipShape(
                    isExpanded ?
                    AnyShape(RoundedRectangle(cornerRadius: 32)) :
                    AnyShape(Circle())
                )
                .onTapGesture {
                    withAnimation(.spring()) {
                        isExpanded.toggle()
                    }
                }

            Text(isExpanded ? "Collapse" : "Expand")
                .font(.caption)
                .foregroundColor(.gray)
        }
        .padding()
    }
}

// 複数の Shape を一つの型で扱うためのラッパー
struct AnyShape: Shape {
    private let closure: (CGRect) -> Path

    init(_ shape: S) {
        self.closure = { shape.path(in: $0) }
    }

    func path(in rect: CGRect) -> Path {
        closure(rect)
    }
}

Tap でアニメーション付きで形状が変わる。withAnimation(.spring()) で自然な動きになる。

iOS 18 での新機能と対応

ResizableShape

iOS 18 では、一部の Shape がリサイズに対応した。従来は Path でカスタマイズが必要だった部分が、より簡潔に書ける。

ただし、iOS 17 以前との互換性を保つなら、条件分岐で対応版と非対応版を分ける必要がある。

互換性の保ち方

@available(iOS 18, *)
struct iOS18View: View {
    var body: some View {
        Image("icon")
            .resizable()
            .scaledToFill()
            .frame(width: 150, height: 150)
            .clipShape(RoundedRectangle(cornerRadius: 12))
    }
}

// iOS 17 以前向け
@available(iOS, introduced: 13.0, deprecated: 18.0)
struct LegacyView: View {
    var body: some View {
        Image("icon")
            .resizable()
            .scaledToFill()
            .frame(width: 150, height: 150)
            .clipShape(RoundedRectangle(cornerRadius: 12))
    }
}

実装時の注意点

resizable() は必須

Image のデフォルトサイズは固定。clipShape を使う前に .resizable() を必ず呼ぶ。さもないと期待サイズに拡大されない。

frame は clipShape の前

順序が大事。frame を先に指定して、その後に clipShape を適用する。逆順だと期待通りにクリップされない。

// ✓ 正しい順序
Image("icon")
    .resizable()
    .frame(width: 150, height: 150)
    .clipShape(Circle())

// ✗ 間違い
Image("icon")
    .clipShape(Circle())
    .frame(width: 150, height: 150)
    .resizable()

高い解像度では shadow を避ける

clipShape + shadow は、複数の render pass が必要になり、パフォーマンスが低下する場合がある。本当に必要でない限り、shadow は避ける。

複雑な Path は計算量に注意

カスタム Path を定義する場合、複雑な計算は避ける。View の再描画のたびに path() が呼ばれるため、重い計算は実行時パフォーマンスに影響する。

よくある間違いと対処

「クリップが効かない」

原因は、大体 .resizable() が漏れているか、frame が指定されていない。Image のデフォルトサイズで試しているケースが多い。

「アニメーション中に形状がちらつく」

clipShape アニメーションの制限。見栄えが重要なら、mask で代替えすること。

「カスタム Shape がうまく動かない」

Shape の path() メソッドが rect に対応しきれていない。デバッグは、異なるサイズで試して、形状が正しく拡大縮小されるか確認。

関連記事

まとめ

clipShape で Image をカスタム形状にクリップするのは、SwiftUI では基本的なテクニック。使い分けは単純。

シンプルな形(丸、角丸)なら clipShape。複雑な形やグラデーションマスクなら mask。この基準で判断すれば、9 割のケースは解決する。

5 つのパターンを把握していれば、ほぼすべての画像クリッピング要件に対応できるはずだ。

Android PDF 作成と表示【完全ガイド】PdfDocument vs PdfRenderer【2026年版】

はじめに

Android アプリで PDF を作成・表示することは、レポート生成、請求書作成、ドキュメント共有など、実務的なアプリケーション開発で頻繁に必要になります。

この記事では、Android で PDF を扱う 2 つの主要な方法(PdfDocumentPdfRenderer)を完全解説します。2026 年時点での最新の API、Kotlin での実装、トラブルシューティングまでをカバーしています。

PdfDocument とは

概要

PdfDocument は、Android 5.0(API 21)で導入された PDF 生成用のクラスです。アプリケーション内でプログラマティックに PDF を作成・レンダリングできます。

  • 用途:PDF ファイルの作成(ジェネレーション)
  • 対応 API:API 19 以上
  • クラスandroid.graphics.pdf.PdfDocument

メリット・デメリット

項目 メリット デメリット
柔軟性 完全なカスタマイズが可能 実装が複雑
依存性 外部ライブラリ不要 Android Framework に依存
パフォーマンス 中程度(小~中規模 PDF) 大規模 PDF では遅延の可能性
用途 レポート、請求書、領収書作成 複雑なレイアウトは困難

PdfRenderer とは

概要

PdfRenderer は、Android 5.0(API 21)で導入された PDF 表示・レンダリング用のクラスです。既存の PDF ファイルを画像としてレンダリングして表示します。

  • 用途:PDF ファイルの表示・レンダリング
  • 対応 API:API 21 以上
  • クラスandroid.graphics.pdf.PdfRenderer

メリット・デメリット

項目 メリット デメリット
シンプル 実装が簡潔 カスタマイズが限定的
パフォーマンス 高速(ネイティブレンダリング) メモリ使用量が多い可能性
出力形式 高品質な画像として表示 PDF ファイル自体の編集は不可
用途 PDF ファイルの表示・閲覧 PDF 生成には不適

PdfDocument vs PdfRenderer 比較表

項目 PdfDocument PdfRenderer
目的 PDF 生成 PDF 表示
対応 API API 19+ API 21+
入力 アプリケーションコード PDF ファイル
出力 PDF ファイル ビットマップ画像
使用難度 中程度(複雑) 簡単
カスタマイズ性 高い 低い
パフォーマンス 中速 高速
主な用途 レポート、請求書、データエクスポート PDF ビューア、ドキュメント表示

実装方法

前提条件(環境セットアップ)

// build.gradle.kts (Module: app)
android {
    compileSdk = 34  // 2026年推奨:API 34-35

    defaultConfig {
        minSdk = 21    // PdfDocument/PdfRenderer対応の最小値
        targetSdk = 34
    }
}

dependencies {
    // Kotlin stdlib
    implementation("org.jetbrains.kotlin:kotlin-stdlib:1.9.0")
}

PdfDocument での実装例

用途:レポート、請求書、ドキュメント作成

// Kotlin での実装例(2026年版)
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.pdf.PdfDocument
import android.os.Environment
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class PdfReportGenerator(private val context: Context) {

    suspend fun generatePdfReport(): File = withContext(Dispatchers.IO) {
        // PDF ドキュメント作成
        val pdfDocument = PdfDocument()
        val pageInfo = PdfDocument.PageInfo.Builder(595, 842, 1).create()
        val page = pdfDocument.startPage(pageInfo)
        val canvas = page.canvas

        // ページに描画
        val paint = Paint().apply {
            textSize = 16f
        }

        canvas.drawText("サンプル PDF レポート", 50f, 50f, paint)
        canvas.drawText("生成日時: 2026年4月15日", 50f, 100f, paint)
        canvas.drawText("内容: Android PDF 生成のデモンストレーション", 50f, 150f, paint)

        pdfDocument.finishPage(page)

        // ファイル保存
        val pdfFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "report.pdf")
        pdfDocument.writeTo(pdfFile.outputStream())
        pdfDocument.close()

        return@withContext pdfFile
    }
}

// 使用例
// val generator = PdfReportGenerator(context)
// val pdfFile = generator.generatePdfReport()

PdfRenderer での実装例

用途:PDF ファイルの表示・閲覧

// Kotlin での実装例(2026年版)
import android.content.Context
import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import java.io.File

class PdfViewerUtil(private val context: Context) {

    fun renderPdfPage(pdfFile: File, pageNumber: Int): Bitmap? {
        return try {
            val fileDescriptor = ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY)
            val pdfRenderer = PdfRenderer(fileDescriptor)

            // ページ数確認
            if (pageNumber >= pdfRenderer.pageCount) {
                return null
            }

            // ページをビットマップとしてレンダリング
            val page = pdfRenderer.openPage(pageNumber)
            val bitmap = Bitmap.createBitmap(page.width, page.height, Bitmap.Config.ARGB_8888)
            page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)

            page.close()
            pdfRenderer.close()

            bitmap
        } catch (e: Exception) {
            e.printStackTrace()
            null
        }
    }
}

// 使用例
// val viewer = PdfViewerUtil(context)
// val bitmap = viewer.renderPdfPage(pdfFile, 0)
// imageView.setImageBitmap(bitmap)

ライブラリを使った簡単実装

より高度な PDF 操作が必要な場合、以下のライブラリを検討してください。

iText(商用・オープンソース)

dependencies {
    // iText 5 (無料版)
    implementation("com.itextpdf:itextg:5.5.13.3")
}

PDFBox(Apache オープンソース)

dependencies {
    // Apache PDFBox
    implementation("org.apache.pdfbox:pdfbox-android:2.0.27.0")
}

権限設定(AndroidManifest.xml)

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- ファイル読み書き権限(Android 13以前)-->
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

    <!-- Android 12+: Scoped Storage自動対応 -->

</manifest>

Kotlin での実装(2026年版 - モダン Android)

coroutine を使った非同期 PDF 生成

// Kotlin Coroutine での実装例
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch

class PdfGeneratorViewModel(private val generator: PdfReportGenerator) : ViewModel() {

    fun generatePdfAsync() {
        viewModelScope.launch {
            try {
                val pdfFile = generator.generatePdfReport()
                // UI 更新
                println("PDF 生成完了: ${pdfFile.absolutePath}")
            } catch (e: Exception) {
                // エラーハンドリング
                println("PDF 生成エラー: ${e.message}")
            }
        }
    }
}

// UI 層での使用例(Jetpack Compose)
@Composable
fun PdfGeneratorScreen(viewModel: PdfGeneratorViewModel) {
    Button(onClick = { viewModel.generatePdfAsync() }) {
        Text("PDF を生成")
    }
}

Android 12+ での変更点

Scoped Storage

Android 12 以降、外部ストレージへのアクセス方法が変わりました。

  • 変更点WRITE_EXTERNAL_STORAGE 権限が無視される
  • 推奨方法getExternalFilesDir() を使用(アプリ固有ディレクトリ)
  • 代替手段FileProvider または Intent.ACTION_CREATE_DOCUMENT
💡 推奨実装パターン:Android 12+ では getExternalFilesDir() を使用してアプリ固有ディレクトリにファイルを保存することが標準実装です。
// Android 12+ での推奨実装
val pdfDir = context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)
val pdfFile = File(pdfDir, "report_${System.currentTimeMillis()}.pdf")

// ファイルの保存・共有
val fileUri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", pdfFile)

// 共有インテント
val shareIntent = Intent(Intent.ACTION_SEND).apply {
    type = "application/pdf"
    putExtra(Intent.EXTRA_STREAM, fileUri)
    addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}

よくあるエラーと対処法

FileNotFoundException

【エラーメッセージ】
java.io.FileNotFoundException: /storage/emulated/0/Documents/report.pdf (Permission denied)
【原因】
- ファイルの保存先に権限がない
- ディレクトリが存在しない
【対処法】
下記のように、Android 12+ 推奨の方法でファイルを保存してください。
// ❌ 間違い
val pdfFile = File("/sdcard/Documents/report.pdf")

// ✓ 正解(Android 12+ 推奨)
val pdfFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "report.pdf")
// または
val pdfFile = File(context.cacheDir, "report.pdf")

SecurityException(Android 12+)

【エラーメッセージ】
java.lang.SecurityException: Permission Denial: opening provider android.content.ContentProvider
【原因】
- 外部ストレージへのアクセス権限不足
- FileProvider の設定不備
【対処法】
1. FileProvider を res/xml/file_paths.xml で設定
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-files-path name="documents" path="Documents/" />
</paths>

2. AndroidManifest.xml で宣言
<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

OutOfMemoryError(大規模 PDF)

【エラーメッセージ】
java.lang.OutOfMemoryError: Failed to allocate [size] bytes
【原因】
- 複数ページの PDF を一度にメモリに読み込み
- 高解像度でレンダリング
【対処法】
// ページごとにレンダリング(メモリ効率化)
fun renderPdfPageSafely(pdfFile: File, pageNumber: Int): Bitmap? {
    return try {
        val fileDescriptor = ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY)
        val pdfRenderer = PdfRenderer(fileDescriptor)

        val page = pdfRenderer.openPage(pageNumber)

        // 低解像度でレンダリング(メモリ節約)
        val scale = 1.5f
        val bitmap = Bitmap.createBitmap(
            (page.width * scale).toInt(),
            (page.height * scale).toInt(),
            Bitmap.Config.RGB_565  // ARGB_8888 より軽い
        )
        page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)

        page.close()
        pdfRenderer.close()

        bitmap
    } catch (e: OutOfMemoryError) {
        e.printStackTrace()
        null
    }
}

実装例のリソース

関連記事

まとめ

Android で PDF を扱う場合、以下の判断基準で実装方法を選択してください。

  • PDF を生成したい場合PdfDocument を使用(または iText などのライブラリ)
  • PDF を表示・閲覧したい場合PdfRenderer を使用
  • 複雑な PDF 操作が必要な場合iTextPDFBox などのライブラリを導入

2026 年時点では、Kotlin + Coroutine での非同期処理が標準的な実装パターンです。また、Android 12 以降の Scoped Storage への対応は必須です。

この記事で紹介したコード例は、実際のプロジェクトに適応させて使用してください。不明な点や実装時のトラブルは、コメント欄でお気軽にお尋ねください。

2026/04/14

iOS UIViewController ライフサイクル完全ガイド【2026年UIKit + SwiftUI対応】

iOS UIViewControllerライフサイクル【2026年UIKit + SwiftUI 対応】

📌 関連記事: この記事は 【iOS】UIViewControllerのライフサイクル (Swift)(2019年)の2026年版アップデートです。UIKit に加えて SwiftUI 対応を追加しました。

2019年の記事では UIViewControlller のライフサイクルを紹介していた。2026年は状況が変わった。SwiftUI が標準になりつつあり、UIKit は「レガシー」扱いになりつつある。

ただ、既存プロジェクトや複雑な UI は UIKit が必須。両方理解すべき。


2019年 vs 2026年:フレームワークの立場

【2019年】

  • UIKit が主流
  • SwiftUI は登場したばかり(iOS 13)
  • ほぼ全員が UIKit を使っていた

【2026年】

  • SwiftUI が標準(iOS 16+対応アプリが主流)
  • UIKit は「既存プロジェクト対応」用途へ
  • 新規プロジェクト:SwiftUI 推奨
  • 既存プロジェクト:UIKit を理解する必要

UIKit:UIViewControllerのライフサイクル

全ライフサイクルメソッド(実行順序)

ビュー表示時

1. init(coder:) または init(nibName:bundle:)
   ↓
2. viewDidLoad()
   ↓
3. viewWillAppear(_:)
   ↓
4. viewWillLayoutSubviews()
   ↓
5. viewDidLayoutSubviews()
   ↓
6. viewDidAppear(_:)

ビュー非表示時

1. viewWillDisappear(_:)
   ↓
2. viewDidDisappear(_:)
   ↓
3. deinit (メモリから削除)

各メソッドの役割

class MyViewController: UIViewController {

    // ① 初期化時(1回のみ)
    override func viewDidLoad() {
        super.viewDidLoad()
        // UI の初期化、データの読み込み
        // 重い処理はここで実行 OK
    }

    // ② ビュー表示直前(毎回呼ばれる)
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // データの更新、画面リフレッシュ
        // 他の ViewController からの復帰時
    }

    // ③ レイアウト計算前
    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        // Auto Layout の前処理
        // ビューのサイズが確定する前
    }

    // ④ レイアウト計算完了
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        // サイズに依存した処理
        // フレーム値が確定した後
    }

    // ⑤ ビュー表示完了
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // アニメーション開始
        // ネットワーク通信開始
        // センサー (GPS など) の監視開始
    }

    // ⑥ ビュー非表示直前
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        // タイマー停止
        // ネットワーク通信キャンセル
    }

    // ⑦ ビュー非表示完了
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        // センサーの監視停止
        // リソース解放
    }

    // ⑧ メモリから削除
    deinit {
        // 最終クリーンアップ
        print("MyViewController が削除されました")
    }
}

SwiftUI:View のライフサイクル

SwiftUI では全く異なる

SwiftUI は 宣言型 UI で、UIViewControlller のような「ライフサイクルメソッド」は存在しない。

代わりに onAppear / onDisappear を使う。

struct ContentView: View {
    @State var count = 0

    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button("Increment") {
                count += 1
            }
        }
        .onAppear {
            // ビュー表示時
            print("View appeared")
            // データ読み込み、API 呼び出しはここ
        }
        .onDisappear {
            // ビュー非表示時
            print("View disappeared")
            // リソース解放
        }
    }
}

SwiftUI のライフサイクル(概念的)

【初期化・表示】

  1. View 作成(body 計算)
  2. onAppear 実行
  3. ビュー表示

【状態変更】

  1. @State 更新
  2. body 再計算
  3. ビュー更新(自動)

【終了】

  1. onDisappear 実行
  2. View 削除

UIKit vs SwiftUI:ライフサイクル比較表

フェーズ UIKit SwiftUI
初期化 init(coder:) View 作成
初回ロード viewDidLoad() onAppear
表示前 viewWillAppear() なし(自動)
レイアウト viewWillLayoutSubviews() 自動(Combine)
表示完了 viewDidAppear() onAppear
非表示前 viewWillDisappear() なし(自動)
非表示完了 viewDidDisappear() onDisappear
終了 deinit View 削除

実装パターン:よくある用途別

UIKit版パターン 1:データの初期化

class UserProfileViewController: UIViewController {
    var user: User?

    override func viewDidLoad() {
        super.viewDidLoad()
        // ❌ ここで API 呼び出しは避ける
        // loadUser()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // ✅ ここで最新データを取得(毎回)
        loadUser()
    }

    func loadUser() {
        API.fetchUser { [weak self] user in
            self?.user = user
            self?.updateUI()
        }
    }
}

SwiftUI版パターン 1:データの初期化

struct UserProfileView: View {
    @State var user: User?
    @State var isLoading = false

    var body: some View {
        VStack {
            if let user = user {
                Text(user.name)
            }
        }
        .onAppear {
            loadUser()
        }
    }

    func loadUser() {
        isLoading = true
        API.fetchUser { user in
            self.user = user
            isLoading = false
        }
    }
}

UIKit版パターン 2:センサーの監視開始・停止

class MapViewController: UIViewController {
    var locationManager: CLLocationManager?

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // ✅ ビュー表示時に監視開始
        locationManager = CLLocationManager()
        locationManager?.startUpdatingLocation()
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        // ✅ ビュー非表示時に監視停止
        locationManager?.stopUpdatingLocation()
    }
}

SwiftUI版パターン 2:センサーの監視開始・停止

struct MapView: View {
    @StateObject var locationManager = LocationManager()

    var body: some View {
        VStack {
            Text("Latitude: \(locationManager.latitude)")
        }
        .onAppear {
            locationManager.startUpdating()
        }
        .onDisappear {
            locationManager.stopUpdating()
        }
    }
}

class LocationManager: NSObject, ObservableObject {
    @Published var latitude = 0.0
    let manager = CLLocationManager()

    func startUpdating() {
        manager.startUpdatingLocation()
    }

    func stopUpdating() {
        manager.stopUpdatingLocation()
    }
}

UIKit版パターン 3:タイマー

class CountdownViewController: UIViewController {
    var timer: Timer?

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
            self.updateCountdown()
        }
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        timer?.invalidate()  // ❌ これを忘れるとメモリリーク
        timer = nil
    }
}

SwiftUI版パターン 3:タイマー

struct CountdownView: View {
    @State var count = 10
    @State var timer: Timer?

    var body: some View {
        Text("\(count)")
            .onAppear {
                timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
                    count -= 1
                }
            }
            .onDisappear {
                timer?.invalidate()  // 自動クリーンアップ
            }
    }
}

メモリリークを避けるためのベストプラクティス

UIKit

❌ メモリリーク:self をキャプチャしすぎ
override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    API.fetchData { data in
        self.updateUI(data)  // self が保持され続ける
    }
}
✅ weak self で回避
override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    API.fetchData { [weak self] data in
        self?.updateUI(data)  // self が解放される
    }
}

SwiftUI

// SwiftUI は自動的に管理してくれる場合が多い
struct MyView: View {
    @StateObject var viewModel = MyViewModel()

    var body: some View {
        Text(viewModel.data)
            .onAppear {
                viewModel.load()  // 自動的に適切にハンドル
            }
    }
}

2019年 vs 2026年:推奨される実装方針

状況 2019年の推奨 2026年の推奨
新規プロジェクト UIKit SwiftUI
既存 UIKit プロジェクト UIKit 継続 UIKit 継続(段階的に SwiftUI 導入)
複雑な UI UIKit UIKit(SwiftUI の限界回避)
学習用 UIKit SwiftUI

まとめ

【2019年】

UIViewController のライフサイクルを理解 = iOS 開発の基本

【2026年】

  • 新規プロジェクト:SwiftUI を使い、onAppear/onDisappear で対応
  • 既存プロジェクト:UIViewController ライフサイクルは依然重要
  • 両方の理解が必須(業界の過渡期)

実装判断:

  • iOS 16+ のみ対応 → SwiftUI
  • iOS 15 以下対応が必要 → UIKit
  • iOS 14 以下対応が必要 → 必ず UIKit

参考資料

2026/04/13

Ollama + Open WebUI:ローカルLLM構築【2026年完全ガイド】

ローカルで LLM を動かすなら Ollama + Open WebUI が標準。2025年の古い記事では断片的だったが、2026年版は実装から運用まで全て網羅。


なぜローカル LLM なのか

【クラウド API(ChatGPT など)】

  • 毎回データが外に出る(セキュリティ問題)
  • API 代がかさむ(月数千~数万円)
  • 速度が遅い場合がある(ネットワーク遅延)

【ローカル LLM(Ollama)】

  • データが自分のサーバーだけ(セキュアー)
  • 無料(初期構築後、ランニング費用ほぼゼロ)
  • 速度が速い(ネットワーク遅延ゼロ)

必要な環境

ハードウェア

【最小構成】

  • CPU:4コア以上(Ryzen 5 相当)
  • メモリ:8GB(推奨 16GB)
  • ストレージ:30GB(モデルサイズ次第)

【GPU あると大幅に高速化】

  • Nvidia GPU:RTX 3060 以上推奨
  • AMD GPU:RX 6700 XT 以上推奨
  • Mac M1/M2:統合 GPU で十分

【GPU なし場合】

  • CPU のみでも動作(ただし遅い)
  • 推論速度:1トークン/秒程度
  • GPU あり場合:10~20倍高速

ソフトウェア

【必須】

  • Docker
  • Docker Compose

【推奨(GPU使用時)】

  • Nvidia GPU ドライバー
  • CUDA Toolkit 12.0+

インストール手順

ステップ 1:Docker & Docker Compose インストール

# Ubuntu/Debian
sudo apt-get update
sudo apt-get install docker.io docker-compose

# 権限設定(sudo なしで実行可能に)
sudo usermod -aG docker $USER
newgrp docker

ステップ 2:docker-compose.yml 作成

version: '3.8'

services:
  ollama:
    image: ollama/ollama:latest
    container_name: ollama
    ports:
      - "11434:11434"
    volumes:
      - ollama_data:/root/.ollama
    environment:
      - NVIDIA_VISIBLE_DEVICES=all
    gpus:
      - driver: nvidia
        all: true  # GPU 全て使用
    restart: always

  open-webui:
    image: ghcr.io/open-webui/open-webui:main
    container_name: open-webui
    ports:
      - "8080:8080"
    environment:
      - OLLAMA_BASE_URL=http://ollama:11434
    depends_on:
      - ollama
    restart: always
    volumes:
      - webui_data:/app/backend/data

volumes:
  ollama_data:
  webui_data:

ステップ 3:起動

docker-compose up -d

# ログ確認
docker-compose logs -f

ステップ 4:モデルのダウンロード

# Ollama コンテナに入る
docker exec -it ollama bash

# モデルをダウンロード
ollama pull llama2:7b
# または
ollama pull mistral:7b
# または
ollama pull neural-chat:7b

ステップ 5:Open WebUI にアクセス

ブラウザで http://localhost:8080 を開く
→ ユーザー登録(初回のみ)
→ モデル選択して利用開始

GPU 設定(高速化のため)

Nvidia GPU の場合

# CUDA Toolkit インストール
sudo apt-get install cuda-toolkit-12-0

# docker-compose.yml で GPU を有効化(上記の例参照)
services:
  ollama:
    gpus:
      - driver: nvidia
        all: true

GPU が認識されているか確認

# コンテナ内で確認
docker exec -it ollama bash
ollama run llama2:7b

# プロンプトで GPU 使用状況を確認
# "GPU acceleration enabled" と表示されたら OK

推奨モデル一覧(2026年)

モデル サイズ 速度 品質 用途
Llama 2 7B 4GB 速い 標準 汎用・最初の1択
Mistral 7B 4GB 高速 高い 日本語・コード
Neural Chat 7B 4GB 速い 高い 会話型・日本語
Llama 2 13B 8GB 中程度 高い より精度重視
Code Llama 7B 4GB 速い コード特化 プログラミング
Mistral Large 34GB 遅い 非常に高い 重い処理用

初心者推奨:Mistral 7B(バランス型)


よくある落とし穴

1. GPU が認識されない

# 確認コマンド
docker run --rm --gpus all nvidia/cuda:12.0-base nvidia-smi

# 出力がなければ、nvidia-docker が必要
sudo apt-get install nvidia-docker2
sudo systemctl restart docker

2. メモリ不足エラー

"OOM Killed" または メモリ不足エラーが出た場合

対策 1:小さいモデルを使う

ollama pull llama2:7b  # 7B は 4GB
# ではなく
ollama pull phi:2.7b   # 2.7B は 2GB

対策 2:スワップメモリを増やす

sudo fallocate -l 16G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile

3. Open WebUI が Ollama に接続できない

# docker-compose.yml で
environment:
  - OLLAMA_BASE_URL=http://ollama:11434

# これが正しくないと接続失敗
# ホスト名は「ollama」(service 名)
# ポートは 11434(Ollama のデフォルト)

4. 応答が遅い(GPU 未使用の場合)

# GPU が使われているか確認
docker exec -it ollama ollama ps

# "GPU acceleration disabled" が出たら GPU 未認識
→ 上記「GPU が認識されない」の対策を実施

実装時のベストプラクティス

1. モデルの永続化

volumes:
  ollama_data:/root/.ollama  # ← ここにモデルが保存される

# コンテナ削除後も モデルが残る
docker-compose down  # コンテナ削除
docker-compose up -d  # 再起動(モデルダウンロード不要)

2. Web UI のセキュリティ設定

open-webui:
  environment:
    - OLLAMA_BASE_URL=http://ollama:11434
    # ローカルホストのみアクセス許可
    - WEBUI_AUTH_REQUIRED=true  # ユーザー認証必須

3. 複数モデルの管理

# ダウンロード済みモデルを確認
docker exec -it ollama ollama list

# 不要なモデルを削除
docker exec -it ollama ollama rm llama2:7b

4. バックアップ

# モデルデータをバックアップ
docker cp ollama:/root/.ollama ./ollama_backup

# WebUI のデータ(会話履歴)をバックアップ
docker cp open-webui:/app/backend/data ./webui_backup

トラブルシューティング

ポート競合

"Address already in use" エラーが出た場合

docker ps  # 既存コンテナを確認
docker stop <コンテナ ID>
docker-compose up -d  # 再起動

メモリリーク

# コンテナのメモリ使用量を監視
docker stats

# メモリが増え続ける場合、モデルを再ロード
docker restart ollama

ネットワーク接続の問題

# コンテナ間の通信確認
docker exec -it open-webui ping ollama

# 失敗する場合、ネットワークドライバを確認
docker network ls
docker network inspect <network_name>

2025年版との変更点

項目 2025年 2026年
モデルサイズ Llama 2 のみ 複数選択肢
GPU 設定 簡潔 詳細なトラブルシューティング
セキュリティ 未記載 ユーザー認証設定
バックアップ 未記載 手順明記
トラブルシューティング なし 充実

次のステップ

【1日目】

  • Ollama + Open WebUI をセットアップ
  • モデル 1つをダウンロード・テスト

【1週間後】

  • GPU 最適化を試す
  • 複数モデルを試して相性を確認

【1ヶ月後】

  • 実運用に向けて、セキュリティ設定を強化
  • API 化(他アプリから Ollama を呼び出し)を検討

参考資料

Android Jetpack Compose LazyGrid で斜めスクロール実装【2026年版】

Android 斜めスクロールView実装 ~2026年Jetpack Compose版

📌 関連記事: この記事は 【android】ScrollViewで縦横斜めにスクロール(2015年)の2026年版アップデートです。当時のコンセプトが、今どう実装されているかをご紹介します。

かつて「Androidで斜めスクロールって実装できないのか?」という問い合わせがあった。2015年時点では標準APIになく、自作が必須だった。今は Jetpack Compose の登場で、状況が大きく変わった。

2026年なら、Compose + LazyGrid で 1行で解決できる。


昔の問題(2015年)vs 現在(2026年)

2015年:框組みで解決しようとしていた時代

ScrollView(縦)+ HorizontalScrollView(横)
  ↓
マージして「斜めスクロール」を実現
  ↓
カスタムViewGroupを自作

当時は効果測定も「コンセプト段階」で、実装されていなかった。

2026年:Jetpack Compose なら標準

LazyVerticalGrid / LazyHorizontalGrid
  ↓
フリングスクロール、慣性スクロール完備
  ↓
nested scroll も自動対応

標準APIs の充実で、カスタム実装が不要になった。


2026年の実装:Jetpack Compose

ステップ 1:依存関係の追加

dependencies {
    implementation "androidx.compose.foundation:foundation:1.7.0"
    implementation "androidx.compose.material3:material3:1.3.0"
    implementation "androidx.activity:activity-compose:1.9.0"
}

ステップ 2:LazyGrid で斜めスクロール実現

@Composable
fun DiagonalScrollView() {
    LazyVerticalGrid(
        columns = GridCells.Fixed(3),
        modifier = Modifier
            .fillMaxSize()
            .padding(8.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(100) { index ->
            GridItem(index)
        }
    }
}

@Composable
fun GridItem(index: Int) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .height(120.dp),
        colors = CardDefaults.cardColors(
            containerColor = Color(0xFF6200EE)
        )
    ) {
        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = "Item $index",
                color = Color.White,
                fontSize = 16.sp,
                fontWeight = FontWeight.Bold
            )
        }
    }
}

ステップ 3:複数方向スクロール(横 + 縦)

単純な縦スクロール + 横スクロール両対応が必要なら:

@Composable
fun BiDirectionalScroll() {
    LazyVerticalGrid(
        columns = GridCells.Fixed(4),
        modifier = Modifier
            .fillMaxSize()
            .horizontalScroll(rememberScrollState())
    ) {
        items(200) { index ->
            GridItem(index)
        }
    }
}
注意:
  • horizontalScroll()LazyVerticalGrid でも使用可能
  • フリングスクロール、慣性スクロールは自動対応
  • パフォーマンスは Compose の仮想化により最適化済み

実装時の落とし穴

1. NestedScrollConnection の設定を忘れずに

親スクロールと子スクロールが両方ある場合:

val nestedScrollDispatcher = remember { NestedScrollDispatcher() }

LazyVerticalGrid(
    columns = GridCells.Fixed(3),
    modifier = Modifier
        .nestedScroll(nestedScrollDispatcher.asNestedScrollConnection())
) {
    // ... items
}

2. パフォーマンス:アイテム数が大きい場合

❌ 悪い例:すべてのアイテムを描画
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
    repeat(10000) { index ->
        GridItem(index)  // 全項目メモリに残る
    }
}
✅ 良い例:仮想化による遅延描画
LazyVerticalGrid(columns = GridCells.Fixed(3)) {
    items(10000) { index ->
        GridItem(index)  // 見える範囲だけ描画
    }
}

3. スクロール位置の保存

ユーザーが別の画面から戻ってきた時、スクロール位置を復元:

val listState = rememberLazyGridState()

LazyVerticalGrid(
    columns = GridCells.Fixed(3),
    state = listState
) {
    items(100) { index ->
        GridItem(index)
    }
}

// スクロール位置を保存
LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .collect { index ->
            // ローカルDBに保存
            saveScrollPosition(index)
        }
}

2015年実装 vs 2026年実装:比較

項目 2015年 2026年
フレームワーク View(XML + Java) Jetpack Compose(Kotlin)
コード行数 100~200行 30~50行
パフォーマンス メモリ負荷大(全アイテム保持) 軽量(仮想化)
実装難度 中~高(カスタムViewGroup必須) 低(標準APIで解決)
フリングスクロール 手実装 自動対応
nested scroll対応 複雑 シンプル
テスト容易性 高(Composable テスト用ツール充実)

代替案:特殊な要件がある場合

要件 1:斜め45度のカスタムスクロール

@Composable
fun DiagonalCustomScroll(
    angle: Float = 45f  // 45度斜めスクロール
) {
    LazyVerticalGrid(
        columns = GridCells.Fixed(3),
        modifier = Modifier
            .fillMaxSize()
            .rotate(angle)  // 斜めに回転
    ) {
        items(100) { index ->
            GridItem(index)
        }
    }
}

要件 2:スクロール速度をカスタマイズ

@Composable
fun CustomSpeedScroll() {
    val scrollState = rememberScrollState()

    Column(
        modifier = Modifier
            .verticalScroll(
                scrollState,
                flingBehavior = ScrollableDefaults.flingBehavior()
            )
    ) {
        repeat(100) { index ->
            Text("Item $index", modifier = Modifier.padding(16.dp))
        }
    }
}

まとめ

2015年:「カスタムViewGroup自作が必須」

2026年:「Compose の LazyGrid で 30行で完成」

Android 開発は急速に進化している。かつての「困難な実装」が「標準機能」に変わるのは珍しくない。

古い記事の実装方法に固執するのではなく、2026年のプラットフォーム能力を活用する方が圧倒的に効率的。


参考資料

最近、Excel で選択したセルの内容を Ollama で要約・添削できる Add-in を作ってみた

最近、Excel で選択したセルの内容を Ollama で要約・添削できる Add-in を作ってみた。

正直なところ、想定外の落とし穴がいっぱいあった。特に「ローカルネットワーク別 PC の Ollama に、Excel Add-in から安全にアクセスする」という部分が、予想以上に複雑だった。その試行錯誤プロセスを記録しておく。


背景:なぜこんなもの作ったのか

仕事で長めの文章を扱うことが多い。メール、レポート、提案書とか。

最初は ChatGPT API で要約・添削をやってみたけど、3 つの理由でやめた:

  1. データを外に出したくない - 機密文書を API に送るのは避けたい
  2. API コストが地味に積もる - 毎日使うと月額が結構かかる
  3. レスポンス待ちが遅い - 社内 LAN 上なら圧倒的に速い
そこで目を付けたのが Ollama。ローカルネットワーク別 PC で Ollama を建てて、そこに直接アクセスできれば、データも保護できるし、レスポンスも速い。

「Excel から直接使えたら最高だな」と思いついて、Add-in を作り始めたのが始まり。


技術構成

実装してみた構成は以下:

【Excel Add-in】
  ↓ HTTPS でロード
https://localhost:8888(React UI の配信)
  ↓ HTTP リクエスト
【Go バックエンド】(localhost:8888)
  ↓ ローカルネットワーク経由
【Ollama API】(http://172.19.10.10:11434)

バックエンド: Go(標準 net/http パッケージ)

  • シンプルに HTTP サーバーを立てるだけなので、フレームワークは不要だと判断
  • 自己署名証明書での HTTPS 対応

フロント: React

  • Office JavaScript API で Excel セルの読み書き
  • Go API への HTTP 通信

Ollama

  • ローカルネットワーク別 PC 上で実行
  • セルの内容を送信 → 要約・添削結果を取得

予想外の落とし穴:Excel Add-in の HTTPS 必須ポリシー

実装し始めてすぐにぶつかったのが、Excel Add-in は HTTP ではなく HTTPS が必須という制約。

「localhost で動かすんだから HTTP でいいか」と思ってた。甘かった。

さらに複雑だったのが、Ollama が ローカルネットワーク別 PC にあること。

構図:
  PC A(Excel を使ってる)
    ↓
  自分の PC(Go + React のサーバー)
    ↓ ローカルネットワーク
  PC B(172.19.10.10 に Ollama が動いてる)

やりたいこと:

  • Excel Add-in の UI(HTML/React)を自分の PC から HTTPS で配信
  • Add-in から Ollama(別 PC)に HTTP でアクセス

問題:

  • Excel Add-in は HTTPS 配信が必須だけど、自己署名証明書では「信頼されていない」と判定される
  • 別 PC の Ollama にはネットワーク経由でアクセスする必要がある

解決策 1:オレオレ証明書を localhost で動かす

まず自己署名証明書を生成した。

openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodes \
  -subj "/CN=localhost"

この証明書と秘密鍵で、Go の net/http サーバーを立ち上げる。

package main

import (
    "net/http"
)

func main() {
    // ハンドラーを登録
    http.HandleFunc("/api/summarize", handleSummarize)
    http.HandleFunc("/api/proofread", handleProofread)

    // HTTPS で起動(自己署名証明書使用)
    http.ListenAndServeTLS(":8443", "server.crt", "server.key", nil)
}

これで https://localhost:8443 で HTTPS サーバーが起動。

ただし、ここまでではまだ不十分。Excel Add-in のコンテキストでは、この自己署名証明書が「信頼されていない」と判定される。


解決策:自己署名証明書 + ネットワークルーティング

ここが工夫の見せどころ。

2 つのポイントで解決した:

  1. localhost で自己署名証明書を使った HTTPS サーバー起動 - Excel Add-in の HTTPS 必須要件を満たす
  2. Go バックエンド - HTML/React を配信しつつ、Ollama への API リクエストを中継
【構図】
Excel Add-in
  ↓ HTTPS でロード
https://localhost:8888
  ↓ HTML/React の配信 + API エンドポイント
【Go サーバー】
  ├─ /(HTML/React UI を配信)
  └─ /api/summarize, /api/proofread(Ollama に転送)
    ↓ HTTP でローカルネットワーク経由
http://172.19.10.10:11434
  ↓
【Ollama API】

実装

まず自己署名証明書を生成。

openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodes \
  -subj "/CN=localhost"

Go のサーバーコード:

package main

import (
    "net/http"
    "net/http/httputil"
    "net/url"
)

func main() {
    // Ollama API へのリバースプロキシを設定
    ollamaURL, _ := url.Parse("http://172.19.10.10:11434")
    reverseProxy := httputil.NewSingleHostReverseProxy(ollamaURL)

    // HTML/React UI を配信
    fs := http.FileServer(http.Dir("./public"))
    http.Handle("/", fs)

    // /api/generate は Ollama に中継
    http.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) {
        reverseProxy.ServeHTTP(w, r)
    })

    // HTTPS で起動(自己署名証明書)
    http.ListenAndServeTLS(":8888", "server.crt", "server.key", nil)
}

React 側から Ollama API を呼び出すときは、相対パス /api/generate を使う。

// React コンポーネント
const handleSummarize = async (cellContent) => {
    const response = await fetch('/api/generate', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({
            model: 'llama2:7b',
            prompt: `要約してください:\n\n${cellContent}`,
            stream: false
        })
    });

    const result = await response.json();
    return result.response;
};

重要なポイント:

  • localhost:8888 で自己署名証明書を使った HTTPS サーバーを起動
  • Excel Add-in のセキュリティ要件(HTTPS 配信)を満たす
  • React は相対パス /api/generate で Ollama に通信(実際には Go バックエンドが仲介)
  • Go バックエンド内で、localhost から 172.19.10.10 へのネットワークルーティングを実現
  • シンプル:プロキシツール不要で、Go だけで実装完結

セルを選択→要約・添削:実装の流れ

実装が決まったら、次は実用機能。

Excel で文章が入ってるセルを選択して、「要約」「添削」ボタンを押すと、Ollama で処理してセルに結果を返す。

流れ:

  1. セル選択時のイベント取得
await Excel.run(async (context) => {
    const cell = context.application.getSelectedData();
    // セルの内容を取得
});
  1. Go API に送信
const summarized = await fetch('https://localhost:8888/api/summarize', {
    method: 'POST',
    body: JSON.stringify({ text: cellContent })
});
  1. Ollama から要約結果を取得
  • モデルはデフォルトで llama2:7b を使用
  • プロンプトエンジニアリングで「要約」「添削」を区別
  1. 結果をセルに書き込み
await Excel.run(async (context) => {
    context.worksheets.getActiveWorksheet()
        .getRange("A1")
        .values = [[result]];
});

実装時の工夫:

  • ローディング表示 - Ollama はレスポンスに数秒かかるので、「処理中...」を表示
  • エラーハンドリング - ネットワーク接続や Ollama が落ちてる場合の対応
  • タイムアウト設定 - 長すぎる文章は処理に時間がかかるので、上限を設ける
  • プロンプト最適化 - 同じモデルでも「要約」「添削」でプロンプトを変える
// 要約用プロンプト
func getSummarizePrompt(text string) string {
    return fmt.Sprintf(
        "以下のテキストを簡潔に要約してください。箇条書きで3点まで。\n\n%s",
        text,
    )
}

// 添削用プロンプト
func getProofreadPrompt(text string) string {
    return fmt.Sprintf(
        "以下のテキストの文法、表現、敬語を確認して、改善案を提示してください。\n\n%s",
        text,
    )
}

実際に使ってみて

完成して 1 ヶ月ほど使ってみた感想。

便利だったこと:

  1. データが外に出ない - 機密文書を扱うときは本当に安心。API に送ってる時間を気にしなくていい
  2. レスポンスが速い - ローカルネットワーク経由なので、ChatGPT API より圧倒的に高速。待ち時間がストレスにならない
  3. コストがゼロ - Ollama はローカル実行なので追加コストなし。PC のリソース(GPU)を使ってる分だけ

予想外だったこと:

  • 要約の質が思ったより良い - llama2:7b で十分実用的。わざわざ大型モデルに乗り換える必要がない
  • 添削より要約の方が使用頻度が高い - 最初は「添削機能メインで作ろう」と思ってたけど、実用では「要約」がメイン用途に
  • 複数人での利用が想定より難しい - 自分の PC でプロキシを立ててるので、他の PC からはアクセスできない。チーム展開するなら、プロキシを別サーバーに移す必要があった

今後の改善案:

  • Ollama の複数モデル対応 - llama2 以外の軽量モデル(mistral など)も選べるようにしたい
  • Web UI での設定画面 - 今は Go コード内でハードコードしてる設定を、UI で変更できるように
  • ローカルプロキシをサーバー化 - チーム利用を想定して、プロキシを別サーバーに移す

技術的な学び

このプロジェクトで学んだことが 3 つ。

1. Excel Add-in のセキュリティ要件は思ったより厳しい

Excel が HTTPS・自己署名証明書・CORS まで厳密にチェックする。SPA(Single Page Application)の世界と違って、企業向けアプリケーションの厳格さを感じた。

2. ローカル接続は「仲介役」として超有効

別ネットワークにある Ollama にアクセスする時、直接アクセスではなくローカルサーバー経由にすることで、セキュリティと利便性のバランスが取れた。

3. ローカル LLM の強さ

API の待ち時間がゼロに等しく、コストもかからない。確度は ChatGPT より落ちるかもしれないが、実務用途(要約・添削)には十分。「LLM の大型化よりローカル化の時代が来たのかも」と感じた。


まとめ

「Go + React + Ollama」という組み合わせで Excel Add-in を作った。

最大の工夫は「自己署名証明書 + ネットワークルーティング」という組み合わせで、ローカルネットワーク別 PC の Ollama に安全にアクセスする仕組み。

完成してみると、想定外の利便性があった。特に「データが絶対外に出ない」という安心感と「レスポンスの速さ」は、クラウド API では代替できない価値。

チーム展開や本番運用となると、プロキシのサーバー化やアーキテクチャの見直しが必要だけど、個人ツールレベルなら「自己署名証明書 + ネットワークルーティング」で十分実用的。

ローカル LLM の活用方法は、これからも増えていくんだろうなと思った。

2026/03/31

【緊急】axiosがサプライチェーン攻撃に遭遇:汚染バージョン(1.14.1 / 0.30.4)と今すぐ取るべき対策

2026年3月31日、npm パッケージ「axios」がサプライチェーン攻撃の被害に遭った。

単なる「脆弱性が見つかった」ではなく、axios のメンテナアカウントが侵害されて、悪意あるバージョンが npm に直接公開された。つまり、ダウンロードして npm install しただけで、マルウェアが仕込まれる可能性がある。

この記事は、「うちの環境で使ってるけど、どうしたらいい?」という判断フローを書いておく。


事件の概要

何が起きたのか

axios のメンテナアカウント(複数疑い)が侵害され、以下の 2 つのバージョンが悪意あるコードを含んで npm に公開された:

  • axios@1.14.1(最新安定版)
  • axios@0.30.4(レガシー版)

どちらも、同じ手口でマルウェアが混入していた。

どのくらい危険か

めっちゃ危険。理由:

  1. axios は超ポピュラー - JavaScript/Node.js 開発で最も使われている HTTP クライアントライブラリ
  2. 直接実行される - npm install 時に postinstall フック で自動実行
  3. 複数プラットフォーム対応 - Windows/Mac/Linux すべてに対応したマルウェア

実際に axios のダウンロード数は週 2000 万超。2~3 時間の公開期間でも、数千~数万の開発者が被害に遭った可能性がある。


技術的な詳細

マルウェアの動作

npm install 時に postinstall フック が実行され、以下の処理が起きる:

  1. プラットフォーム判定 - OS(Windows/Mac/Linux)を自動判定
  2. マルウェア配置 - 各 OS の隠蔽しやすい場所に RAT(リモートアクセストロージャン)を配置
  3. C2 サーバーへの接続 - sfrclak[.]com:8000 へ接続
  4. トレース消去 - node_modules から証拠を削除

各 OS でのマルウェア配置先

OS 配置先 特徴
macOS /Library/Caches/com.apple.act.mond キャッシュフォルダに偽装
Windows %PROGRAMDATA%\wt.exe, %TEMP%\6202033.vbs システムフォルダに混在
Linux /tmp/ld.py /tmp に配置(起動時消滅する可能性)

含まれていた悪意あるパッケージ

公式記述では「plain-crypto-js」という偽パッケージ。これが dependencies に追加されて、npm install 時に自動ダウンロード・実行される。


うちは大丈夫?:判断フロー

ステップ 1:package.json / package-lock.json を確認

# 正確な確認方法
grep -E "axios.*1\.14\.1|axios.*0\.30\.4" package-lock.json

もし上記コマンドで何か出たら要注意。

ステップ 2:実際にインストールされているバージョンを確認

npm list axios

出力例:

└── axios@1.14.0

1.14.0 以下、または 0.30.3 以下なら OK。1.14.1 または 0.30.4 なら危険。

ステップ 3:「うちのチームはいつこれをインストールしたのか?」を確認

npm install したのが 3月31日の 2~3 時間の間 だったかどうかで、被害の可能性が変わる。

  • 3月31日より前にインストール: 被害なし
  • ⚠️ 3月31日 12:00~15:00 GMT 付近でインストール: 被害の可能性あり
  • 3月31日 15:00 以降でインストール: 被害なし(既にパッチ済み)

リスク評価:うちは被害に遭ったのか?

ケース 1:インストールしたけど実行環境が本番ではない(社内 PC のみ)

リスク度: 中~高

対応:

  1. 該当の PC のマルウェアをスキャン(下記参照)
  2. API キー・パスワードをすべてローテーション
  3. git commit / push のログをチェック(悪意あるコードがコミットされていないか)

ケース 2:本番環境で実行中

リスク度: 超高

すぐに以下を実行:

  1. サーバー再起動
  2. 認証情報のすべてをローテーション(API キー、DB パスワード、SSH キーなど)
  3. アクセスログ確認sfrclak[.]com への外部通信がなかったか)
  4. セキュリティ監査(外部セキュリティ企業に委託検討)

ケース 3:Docker / CI/CD パイプラインで npm install を実行

リスク度: 超高

  • Docker イメージが作成されて、本番環境にデプロイされている場合
  • GitHub Actions などの CI パイプラインで実行してた場合
  • → 該当期間の全デプロイを疑う

対応:

  1. 該当期間の全デプロイを一覧化
  2. 本番環境から当該バージョン削除
  3. 全認証情報ローテーション

すぐにやるべきこと:修復手順

ステップ 1:axios をダウンロードグレード

npm install axios@1.14.0 --ignore-scripts

なぜ --ignore-scripts が必要か

  • もし 1.14.1 がまだキャッシュに残ってたり、中途半端な状態だと危険
  • グレードダウン時も postinstall フック を無視して安全にする

ステップ 2:node_modules の手動確認

# 疑わしいファイルを探す
find node_modules -name "plain-crypto-js" -type d
find node_modules -name "*.vbs" -o -name "*.exe" -o -name "*.py"

見つかったら、即座に削除:

rm -rf node_modules/plain-crypto-js
npm ci  # package-lock.json から再インストール

ステップ 3:マルウェアスキャン(PC にインストールされている場合)

Windows:

Start-MpScan -ScanType FullScan

macOS:

# マルウェアバイトスキャン推奨
# https://www.malwarebytes.com/mac から Malwarebytes ダウンロード・実行

Linux:

sudo freshclam  # パターンアップデート
sudo clamscan -r /tmp /home /root --remove

ステップ 4:全認証情報のローテーション

感染可能性がある環境では、以下をすべてローテーション:


本番環境への影響判定

チェックリスト

すべて「Yes」に近いなら、セキュリティ監査推奨。


実装側の判断:パッケージアップデートはいつする?

axios の対応タイムライン:

バージョン リリース 判定
0.30.3 以下 2026/3/31 18:00 ✅ 安全
1.14.0 以下 2026/3/31 18:00 ✅ 安全
1.14.1 2026/3/31 12:00-15:00 ❌ 危険
0.30.4 2026/3/31 12:00-15:00 ❌ 危険
1.14.2 以上 2026/3/31 15:00 ✅ パッチ済み
0.30.5 以上 2026/3/31 15:00 ✅ パッチ済み

推奨

  • 本番環境: 1.14.2 以上 または 0.30.5 以上 に即座にアップデート
  • 開発環境: 余裕があれば同じく最新版へ

今後のためのセキュリティ対策

npm パッケージ監視ツールの導入

# npm audit で定期チェック
npm audit

# dependabot で自動 PR 生成(GitHub)
# Settings → Code security and analysis → Enable Dependabot

CI パイプラインでのセキュリティスキャン

# npm audit を CI に組み込む
npm audit --audit-level=moderate

package-lock.json の厳密管理

# 本番環境では必ず --frozen-lockfile を使う
npm ci --frozen-lockfile
# 決して npm install を使わない

最後に:この事件が教えてくれること

サプライチェーン攻撃は「あり得ない」ではなく「あり得る」。

axios は信頼性の高いパッケージだが、それでも被害に遭った。理由は、人気が高いがゆえに、攻撃者にとって「おいしいターゲット」だったから。

対策:

  1. すべてのパッケージを信用しない - npm audit を常態化
  2. 本番環境を隔離 - 必ず --frozen-lockfile を使う
  3. credentials の定期ローテーション - 3~6ヶ月ごと
  4. ネットワークログの監視 - 不審な外部接続を検出

2026 年も、サプライチェーン攻撃の脅威は続く。