鯰の住処

Namazuが真面目な話を書きます

Jetpack ComposeのNestedScrollによるスクロール幅を自由に制御する

Jetpack ComposeのLazyListやLazyColumnはNestedScrollがデフォルトで有効になっています。 ところが、部分的に無効にしたい場合やスクロールイベントを要素のスクロール以外に使いたい場合もあり得ます。 このブログではNestedScrollの挙動を解説しつつ、実現したい挙動とその実現方法を逆引き式に紹介します。

NestedScrollを構成する二つの要素

NestedScrollの実現において、NestedScrollに含まれるComposableが必ず持つ必要がある重要な要素は以下の二つです。

  • NestedScrollDispatcher
  • NestedScrollConnection

NestedScrollDispatcherはユーザーがスクロールをしようと試みたComposable、すなわちスクロールイベントを受け取った(NestedScrollで最も子供にあたる)要素が用いるもので、NestedScrollイベントを発火します。このクラスはfinalで定義されており、継承による挙動の変更などは出来ません。(する必要もないはずです)

NestedScrollConnectionはNestedScrollDispatcherによって発火されたスクロールイベントを親へと伝えていく役割を果たします。interfaceとして定義されており、スクロールの前後やフリングの前後に呼ばれるメソッドを実装することが出来ます。

今回重要になるのはNestedScrollConnectionです。

NestedScrollConnectionの各メソッドとその役割

NestedScrollConnectionは以下の4つのメソッドを持っています。

  • onPreScroll
  • onPostScroll
  • onPreFling
  • onPostFling

onPreScroll

    /**
     * Pre scroll event chain. Called by children to allow parents to consume a portion of a drag
     * event beforehand
     *
     * @param available the delta available to consume for pre scroll
     * @param source the source of the scroll event
     *
     * @see NestedScrollSource
     *
     * @return the amount this connection consumed
     */
    fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero

onPreScrollはスクロールを行う(実際にタップした要素を移動させる)前に呼ばれるメソッドで、スクロール幅(available)を用いて何らかの処理を行うことが出来ます。 返り値はonPreScroll関数によって消費されたスクロール幅となっています。デフォルト実装では何もせずスクロール幅も消費しないという意味で、Offset.Zeroがただ返されるだけの関数となっています。

onPostScroll

    /**
     * Post scroll event pass. This pass occurs when the dispatching (scrolling) descendant made
     * their consumption and notifies ancestors with what's left for them to consume.
     *
     * @param consumed the amount that was consumed by all nested scroll nodes below the hierarchy
     * @param available the amount of delta available for this connection to consume
     * @param source source of the scroll
     *
     * @see NestedScrollSource
     *
     * @return the amount that was consumed by this connection
     */
    fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset = Offset.Zero

onPostScrollはスクロールを行った後に呼ばれるメソッドで、この要素とその子孫要素により消費されたスクロール幅(consumed)と残されたスクロール幅(available)を用いて何らかの処理を行うことが出来ます。 返り値は親のonPostScroll関数がavailableとして受け取ることのできるスクロール幅となっています。デフォルト実装では何もせずスクロール幅も消費しないという意味で、Offset.Zeroがただ返されるだけの関数となっています。

onPreFling

    /**
     * Pre fling event chain. Called by children when they are about to perform fling to
     * allow parents to intercept and consume part of the initial velocity
     *
     * @param available the velocity which is available to pre consume and with which the child
     * is about to fling
     *
     * @return the amount this connection wants to consume and take from the child
     */
    suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero

onPreFlingはフリング(スクロールの途中で指を離した後、速度を徐々に落としながらスクロールを継続する動作)を始める前に呼ばれます。 親要素が受け取ることが可能なフリング速度(available)を受け取り、onPreFlingで消費された速度を返り値として返します。 デフォルト実装では何もせず速度も消費しないという意味で、Velocity.Zeroがただ返されるだけの関数となっています。

onPostFling

    /**
     * Post fling event chain. Called by the child when it is finished flinging (and sending
     * [onPreScroll] & [onPostScroll] events)
     *
     * @param consumed the amount of velocity consumed by the child
     * @param available the amount of velocity left for a parent to fling after the child (if
     * desired)
     * @return the amount of velocity consumed by the fling operation in this connection
     */
    suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
        return Velocity.Zero
    }

onPostFlingはフリングを終了する際に呼ばれます。 この要素とその子孫要素により消費されたフリング速度(consumed)と親要素のために残されたフリング速度(available)を受け取り、親がavailableとして受け取ることが可能な速度を返り値として返します。 デフォルト実装では何もせず速度も消費しないという意味で、Velocity.Zeroがただ返されるだけの関数となっています。

NestedScrollのカスタマイズとその実装

サンプルとして、LazyColumnの中に3つのLazyColumnが含まれているリストを作成しました。デフォルトの挙動は以下の通りです。

親がNestedScrollされないようにしたい

親のスクロールイベントは**発火されます。 そのため、以下のようにこの要素のonPostScrollで全てのスクロール幅を消費することにより、親が消費可能なスクロール幅がないと伝えれば良いです。

override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = available

Flingを無効化したい

Flingが実行される前に、自身が受け取ることが出来るフリング速度を0にすればよいです。 すなわち、以下のようにonPreFlingで全てのフリング速度を消費したことにすれば実現できます。

override suspend fun onPreFling(available: Velocity): Velocity = available

折り畳みツールバーと連携させたい

折り畳みツールバーの挙動は、スクロールに連動してツールバーの高さを変えます。 この場合はonPrescroll内でツールバーの高さを変更する処理を記述しつつスクロール幅は消費しなければ期待する挙動が実現できます。 この例は以下の通りandroid developers公式のページで紹介されているものです。

developer.android.com

追記

サンプルコード上げておきました

gist.github.com

PokeApp作成記録

今年初めにPokemon LEGENDS アルセウスをプレイしましたが、実は人生で初めて遊ぶポケモンでした。 折角なのでライブラリの試用がてらポケモンに関連したアプリを作ってみました。 このブログでは作成したアプリの概要と使用したライブラリについて記述しています。

GitHub: https://github.com/BlondeNamazu/pokeapp

アプリの動作概要

PokeAPIを利用し、ポケモン一覧の取得/詳細情報の閲覧/お気に入りの登録が出来るアプリを作成しました。

ポケモン一覧画面

/pokemonエンドポイントを叩き、ポケモンの図鑑No.と名前を一覧表示しています。

ポケモンの名前が書かれたアイテムをタップすることで、そのポケモンの詳細画面に遷移することができます。

ポケモン詳細画面

/pokemon/{id}エンドポイントを叩き、ポケモン詳細情報を表示しています。

名前の横のお気に入りボタン(ハートマーク)を押すことで、そのポケモンをお気に入りに登録出来ます。

お気に入りポケモン一覧画面

お気に入り登録したポケモンの一覧を表示しています。(PokeAPIは関係ありません)

ポケモンの名前が書かれたアイテムをタップすることで、そのポケモンの詳細画面に遷移することができます。 また、名前の横のお気に入りボタンを押すことでお気に入り登録を解除できます。

以下、便宜上ポケモン一覧画面とお気に入りポケモン一覧画面のある画面をホーム画面と呼びます。

使用ライブラリ

主に使用したライブラリの一覧は以下です。

以下は、ライブラリを触ってみた所感です。

Jetpack Compose

xmlで記述するAndroid Viewと比べてかなり書きやすくなったと感じます。 レイアウトをコードで記述出来るという根本的な部分が個人的に好みなだけでなく、プレビュー機能なども充実しておりかなり実用的です。 また、xmlで記述する場合はViewのスタイルの指定などで必要以上にファイルが増えてしまうことも少なくありませんでしたが、それに悩まされないところも嬉しいです。

(Compose) Navigation

今回はホーム画面内でポケモン一覧画面とお気に入りポケモン一覧画面の間の遷移でCompose Navigationを利用しました。 BottomBar(BottomNavigationBar)と組み合わせており、BottomBarのアイコンをタップすると対応するページに遷移することが出来ます。 Compose Navigationを用いる場合は遷移先の情報をStringで持つ必要があり、BottomBarと組み合わせる場合はアイコンやラベルもつけることとなります。このあたりの遷移先情報はenum classでまとめてスッキリさせています。

さて、今回はCompose Navigationと既存のFragmentのNavigationの両方を使用してみたのですが、個人的には現時点でCompose Navigationをアプリ全体で利用することは難しいと考えています。 特に業務で開発するような大規模なアプリケーションでは全ての遷移を1つのNavigationで書くことは難しいですし、遷移先情報をStringで管理するということもメンテナンスコストが高くなるのではないかと懸念しています。 そこで基本的にはFragmentのNavigationを使い、一部の小規模な遷移が必要な画面でCompose Navigationを使用していくという使い分けが良いのではないかと考えています。今後のアップデートで利便性が向上し、Compose Navigationを使用したくなる場面が増えていくことを楽しみにしています。

Paging

今回最も振り回されたのはPaging Libraryかもしれません。 元々ポケモン一覧画面ではポケモンの画像も同時に表示したかったのですが、PokeAPIではページングが可能な一覧API/pokemon)ではポケモンの名前と詳細情報を取得するためのURLのみが返ってくるという仕様の都合上断念しました。 せめて詳細画面を開くなどして画像URLを含む詳細情報を取得したポケモンに関しては、一覧取得APIの情報とRoomに保存した情報を組み合わせて表示することも考えたのですがPaging Libraryではその方法が禁止されています。 業務で使用する際はこのあたりも考慮したうえでAPI設計をする必要がありそうです。

Room

ポケモンのお気に入り状態を含むポケモン詳細情報に関しては、Roomに保存された情報をSSOTとしました。 画面ごとに情報を持つような仕様にしてしまうと情報の不整合が起こりやすくなるため、アプリ全体で参照する情報は可能な限り情報源から直接参照する形が理想的です。 Roomではこのサポートが強力で、Flowと組み合わせることで情報の更新にも対応出来るようになっています。 クライアントのDBはAPIから取得したデータのキャッシュとしての役割が大きいですが、このサポートを活用するメリットは大きいので積極的に利用していきたいライブラリの一つです。

さいごに

一旦ブログまで書きましたが、まだまだ改善出来る部分はあるので暇を見つけて改善を入れていきたいと思います。 次はサーバーサイドとかも書くアプリ作りたいですね。

【Android】 Fragmentが持つFlowのobserverはviewlifecycleOwner.lifecycleScopeで監視させるのが良い

概要

ViewModelが発行するSharedFlowに対しFragmentでObserverを設定していたのですが、Fragmentがback stackから戻った際にObserverが二重で登録されてしまうバグに遭遇しました。 FragmentのライフサイクルとLifycycleOwnerの挙動が主な原因でした。 結論としてはFragmentでlifecycleScopeではなくviewLifecycleScopeを使えば良かったのですが、気になったのでこの記事で深堀します。

バグが起きた状況

FragmentAのonCreateViewで以下のようなコードを書いていました。

override onCreateView() {
  viewModel.hogeFlow.onEach {
     fuga()
  }.launchIn(viewLifecycleOwner.lifecycleScope) 
}

そして、以下の状況でバグが起きました。

  1. FragmentAに遷移する
  2. FragmentAからNavigationの遷移でFragmentBに遷移する
  3. FragmentBからpopBackStack()でFragmentAに戻ってくる
  4. FragmentAでhogeFlowに対するobserverが二重に登録されてしまう(fuga()が二回呼び出される)

この際、実際に起こっていたことは以下の通りです。

  1. FragmentAでonCreateViewが呼ばれ、hogeFlowに対しobserverがセットされる
  2. FragmentAからFragmentBへの遷移に際してFragmentAがback stackに積まれる(ここで購読がキャンセルされない)
  3. FragmentBからpopBackStackでFragmentAに戻る際、FragmentAがback stackから取り出される
  4. FragmentAでonCreateViewが呼ばれ、hogeFlowに対しobserverがセットされる

この時FragmentAのライフサイクルの挙動は公式ドキュメントの図が分かりやすいです。

https://developer.android.com/images/fragment_lifecycle.png?hl=ja

さて、今回バグが起きた原因はFragmentのlifecycleScopeの挙動が影響します。 上の図に示されている通り、Fragmentがback stackに行っただけではFragmentは破棄されず、onDestroy()が呼ばれない(StateがDESTROYEDにならない)のでした。 バグを起こした主な要因は「実際に起こっていたこと」の2番でhogeFlowの購読がキャンセルされていると勘違いしていたことにあります。 Flowのobserve(今回はonEach)メソッドは、launchInで渡されたLifecycleScopeのON_DESTROYイベントが発火された時に購読をキャンセルしますが、今回渡したFragmentのライフサイクルはback stackに移っただけではDESTROYEDになっていないため購読がキャンセルされません。

解決策

FragmentにはFragment自体のライフサイクルの他にFragmentのViewのライフサイクルが用意されており、それがviewLifecycleOwnerです。 ViewLifecycleOwnerのLifecycleScopeではFragmentのonDestroyViewが呼ばれた時にON_DESTROYが発火されます。 Fragmentがback stackに遷移する際にonDestoryViewが呼ばれること、FlowのobserveメソッドのlaunchInで渡されたLifecycleScopeのON_DESTROYイベントが発火された時に購読をキャンセルすることを考慮すると、今回はこのViewLifecycleOwnerを使えば良さそうです。

すると、コードは以下のようになります。

override onCreateView() {
  viewModel.hogeFlow.onEach {
     fuga()
  }.launchIn(viewLifecycleOwner.lifecycleScope) 
}

前回との差分はlaunchInで渡していたlifecycleScopeをviewLifecycleOwner.lifecycleScopeに変更しただけですが、理由が分かると納得できますね。

参考文献

同じ問題について書かれていた先達の記事

補足

FragmentのViewLifecycleOwnerとLifecycleOwnerでON_DESTROYの発火はそれぞれ以下で記述されているようです。

ソースコード

    public void onDestroyView() {
        mCalled = true;
        if (mView != null) {
            mViewLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY);
        }
    }

ソースコード

    void performDestroy() {
        mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY);
        if (mChildFragmentManager != null) {
            mChildFragmentManager.dispatchDestroy();
        }
        mState = INITIALIZING;
        mCalled = false;
        mIsCreated = false;
        onDestroy();
        if (!mCalled) {
            throw new SuperNotCalledException("Fragment " + this
                    + " did not call through to super.onDestroy()");
        }
        mChildFragmentManager = null;
    }

MonoGameを使ってWindows/Androidでソースコードを共有しよう

初めまして、Namazuです。 これはXamarin Advent Calendar2016の14日目の記事です。 今回はMonoGameのお話をしたいと思います。

MonoGameとは?

XNAを移植したゲームフレームワークです。使用する言語はC# XNAMicroSoftが開発したゲームエンジンで、WindowsやWindowsPhone、Xboxにおいてソースコードをほとんど共通にして動作させることが可能なフレームワークです。

ソースコードを共有!

なんか素晴らしい響きですがXNAでは対応するプラットフォームが少なすぎますよね?? そこでMonoGameの出番です。MonoGameの対応するプラットフォームは以下の通り!!

大体ゲームを作ろうとして困ることはないほどの数です。 とくに、AndroidiOSMac OS Xに対応してくれているのがとてもありがたいです。

MonoGameを最大限生かすために

さて、それではMonoGameの利点を最大化するためにはどうするか。 ズバリ、この二つを組み合わせて使うことです!

出ました!!!!Xamarin!!!!!!!!

XamarinでAPI等を提供してもらって、MonoGameの仕組みとVisual StudioのSharedProjectを活かすと…? pico1_03.png pico2_03.png piko3_03.png

複数プラットフォームでソースコードをほとんど共有化!!

やったぜ!!

さらにリソースの読み込みも共有化できちゃう!!

実際に書いてみる

流石にゲーム作るのは大変なので画像表示するだけで許してください。 今回は、WindowsAndroidで同じコードで動かしてみます。

のインストールは各自で行ってください。 (多分これ読んでる人たちプロだから大丈夫だと信じてる)

それでは、VisualStudioを起動して

 File->New->Project->Installed/Templates/Visual C#/MonoGame->MonoGame Windows Project

を作成します。 ProjectNameとSolutionNameは、 Solution ├WindowsProject ├AndroidProject └SharedProject という構造を考えて決めてください。 今回は forAdC ├forWindows ├forAndroid └SharedProject という名前をつけました。

しばらくするとエディタの準備が整うので、右側のSolutionExplorerからSolution'forAdC'を右クリックして

Add->New Project->Installed/Templates/Visual C#/MonoGame->MonoGame Android Project

からAndroid用のプロジェクトを作成して追加してください。 ここでまたプロジェクト名を決めることができます。(デフォルトはGame1) 同様に、

Add->New Project->Installed/Templates/Visual C#/Windows/SharedProject

から共有用プロジェクトを作成して追加してください。

すると、以下のような画面になるかと思います。 image まだ中身は何も弄っていません。

それでは、早速まとめましょうか。 forWindowsでは始めにProgram.csを起動しGame1.csを呼び出す形になっています。 forAndroidでは初めにActivity1.csを起動しGame1.csを呼び出す形になっています。 Game1.csの内容はほぼ同一ですので、これをまとめることができます。

Game1.csをSharedProjectに移動してそれを各プロジェクトから呼び出す形にしましょう。

forWindowsかforAndroidのどちらかからGame1.csをSharedProjectにドラッグ&ドロップでコピーしましょう。 そして、SharedProject以下のGame1.csのnamespaceを「SharedProject」(SharedProjectのプロジェクト名と同一)にします。

その後forWindowsとforAndroidのGame1.csを削除してください。

この時点でforWindowsのProgram.csを見てみると、

new Game1()

の部分でエラーが出ています。参照すべきGame1.csが見つからないからですね。 SolutionExplorerのforWindowsを右クリックし、Add ReferenceからSharedProjectを追加します。 その後SharedProjectをインポートします。

using SharedProject;

ここまで正しく書いてもSharedProjectが見つからないというエラーが出ることもありますが、一度ソリューションを閉じて再度開いてみると直ります。ソリューションファイルを読み込み直さなくても正常にビルドできます。

これで前準備が整いました。ビルドしてみてください。 青い画面が表示されたら勝ちです。

それでは、画像を追加しましょうか。

コードを書く前に、画像を準備しておかなければなりません。 MonoGameではコンテンツ(リソース)は別途ビルドする必要があります。

forWindowsのContent->Content.mgcbをエクスプローラで開きダブルクリックし、MonoGame Pipelineを起動します。 File->Newから新しいmgcbファイルを作成します。 場所は実はどこでも良いのですが、共有化してる感じを出すためにエクスプローラーでforAdCフォルダ直下にContentフォルダを作り、その下にContent.mgcbを新しく作成してください。 image その後、MonoGame Pipelineの画面が少しおかしいので引っ張って直します。 直したあとはこんな感じになります。 image その後、Add Existing Itemから追加したい画像ファイルを選択してください。 追加する際はCopy the file to the directoryを選択してください。

そしてBuildからビルドします。 ※Add Existing Item(ファイルのアイコン)、Build(歯車のアイコン)はツールバーから選択できます。 こんな感じになれば成功です。 image 成功したらMonoGame Pipelineは閉じてしまって構いません。

それでは新しく作成したContent.mgcbをforWindows,forAndroidで読み込めるようにしましょう。 まずはforWindowsについて、Content以下にあるContent.mgcbを削除してください。 そして、SourceExplorer上でContentフォルダを右クリックしてAdd->Add Existing Itemから先ほど作成したContent.mgcbを追加してください。その際、「Add」ではなく「Add as a link」を選択するようにしてください。 するとSourceExplorerにリンクとしてのContent.mgcbが表示されますので、右クリックしてPropertyのBuild ActionをMonoGameContentReferenceに設定してください。 forAndroidでも同様の作業を行ってください。

それではコードを書き換えます。 Game1.csを以下のように書き換えてください。(コピペで構いません) なお、Namazuとあるところは追加した画像のファイル名(拡張子なし)としてください。

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

namespace SharedProject
{
    public class Game1 : Game
    {
        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;
        Texture2D texture;
        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            graphics.IsFullScreen = false;
            graphics.PreferredBackBufferWidth = 800;
            graphics.PreferredBackBufferHeight = 480;
            graphics.SupportedOrientations = DisplayOrientation.LandscapeLeft | DisplayOrientation.LandscapeRight;
        }

        protected override void Initialize()
        {
            // TODO: Add your initialization logic here

            base.Initialize();
        }

        protected override void LoadContent()
        {
            // Create a new SpriteBatch, which can be used to draw textures.
            spriteBatch = new SpriteBatch(GraphicsDevice);
            texture = Content.Load<Texture2D>("Namazu");

            // TODO: use this.Content to load your game content here
        }

        protected override void UnloadContent()
        {
            // TODO: Unload any non ContentManager content here
        }

        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
                Exit();

            // TODO: Add your update logic here

            base.Update(gameTime);
        }

        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);

            // TODO: Add your drawing code here

            spriteBatch.Begin();
            spriteBatch.Draw(texture, new Rectangle(0, 0, 400, 400), Color.White);
            spriteBatch.End();

            base.Draw(gameTime);
        }
    }
}

さて、ここまでで必要な作業は終了です。 実際に表示してみましょう。 forWindows image

forAndroid 5662.png

できたー!!!!!!

ということで今回やることは以上です。 お付き合いありがとうございました。

MonoGameの抱える問題

  • 日本語文献が少ない
  • Androidの文献が少ない

くらいでしょうか? XNAのdocumentを読んでも具体的な実装方法が分からず試行錯誤、なんてこともしょっちゅうです。 もう少し日本語で分かりやすくしてくれれば日本でも普及していくと思うのですが… 私も日本語文献の一助になればと思いブログを書いておりますのでよろしければそちらもご覧ください。

参考文献

git再入門

git再入門

この記事は,

  • gitを用いた共同開発をしたことがある
  • 操作ミスの直し方が分からなくて詰む
  • コマンドが色々あるらしいけど覚えきれない

くらいの方に向けたgitの解説記事です.私自身のためのメモでもあります. gitの基本的な構造に触れつつ,各コマンドが実際何をしているかを解説するのが主な目的です. また,コマンドを知らなくてもggる時の適切なキーワード選びが出来るようになれば幸いです. 第一章・第二章でgitの基本的構造,第三章でコマンド解説をします.

第一章 git repositoryの構造

git repositoryとは,所謂「ファイルやディレクトリの歴史」のことです. SourceTreeなどで目にするコミットグラフはgit repositoryを可視化したものです. 第一章では,git repositoryの構造とともに,関連する用語として

  • commit
  • branch
  • tag

を解説します.

commitはsnapshot

端的に言うとcommitは管理下にある1 ファイルやディレクトリのsnapshotです. よくある勘違いとして「commitは前のcommitからの差分」という説がありますが,違います. snapshotとは「その時の状態の複製」です.すなわち,ファイルが変更されてcommitされる度にそのコピーが作成されアーカイブされています.2

さて,このsnapshotは撮るだけでは意味がなく,参照できなくてはいけません. そのための情報をsnapshotと一緒にまとめたものがcommitです. 各commitは以下の情報を持ちます.

  • snapshot
  • commit hash (id)
  • parent commit
  • commit message

commit hash (id)は各commit固有の文字列です.40文字程度の英数字から成りますが,ほとんどの場合先頭7文字で識別可能です. parent commitはそのcommitの派生元となるcommitのことです.大抵parent commitは一つですが,時々0個や2個以上の場合もあります.3 commit messageはcommit時に付与された,そのcommitの説明です.commit messageは後で見る人(∋自分)のためにきちんと書きましょう.

一旦commitが作られた後は,これらの情報が変更されることは絶対にありません. commit message一つ変更するにしても,必ず新しいcommitが作成されます.

branch,tagはcommitを指すポインタ

branchやtagはどちらも「あるcommitへの参照」に他なりません. より正確に言えばcommit hashのエイリアスです. 情報の勉強をした人ならばポインタと言えばピンと来るかもしれません. 特にブランチについては「元となるブランチから分岐した時点からの歴史」と勘違いされることも多いですが,これも違います.

コミットグラフを作成するときを考えてみましょう.各commitはparent commitを知っていますので,どのcommitもやがてはroot commitに辿り着くことが出来ます.しかし,自身をparent commitとするcommitは知りませんので,各派生先の最先端のcommitを別途参照出来る必要があります.その役割を果たすのがbranchです. branchの参照先は,現在の参照先のcommitから派生するcommitが作成される度に更新されてゆきます. しかし,特定のcommitへの参照を残しておきたい場合もあります.その場合はtagを用いることで,そのcommitへの参照を普遍的に持ち続けることが出来ます. branch,tagはまとめてreferenceと呼ばれます.

第二章 commitが出来るまで

第一章では「commitが出来てから」の話をしましたが,第二章では「commitが出来るまで」の話をします. ここで解説する用語は以下の三つです.

  • worktree
  • index
  • repository

ファイルに変更を行ってからそれをcommitに含めるまでの流れを解説します.

worktreeとは,作業中のディレクトリの状態

第一章でcommitはsnapshotを持つと解説しましたが,考え方によっては現在作業中のディレクトリ自体も一つのsnapshotであると言えます.これがworktreeです. すなわち,worktreeとは作業中のディレクトリの状態そのもののことです.難しく考える必要はありません. ファイルを編集して保存すればその内容は即座にworktreeに反映されます.

indexとは,commitとしてアーカイブされる前のsnapshot

現在編集中のworktreeの状態と,アーカイブとして残したいsnapshotの状態は必ずしも一致しません.そこで,「commitとしてアーカイブされる前の,編集可能なsnapshot」としての役割をindexが担います. 例えば,機能を開発している途中にcommit間の差分が大きくなりすぎると感じた場合は現在のworktreeで行った変更を分割したいと考えます.この時,その状態まで手動でworktreeの状態を戻してしまうと二度手間になります.indexの存在はこういった状況を解決してくれます. 結果として,worktreeの一部または全部の状態をindexに反映させてゆき,indexがアーカイブしたいsnapshotになったらcommitとしてアーカイブする,という流れでcommitが作成されます.

補足,HEADについて

コミットグラフで,HEADという名前を目にしたことがあると思います. HEADはreferenceの一つで,「現在のworktreeの元となるcommit」への参照を持ちます. しかし通常HEADはいずれかのbranchと同じcommitを指すこととなっており,その場合HEADが指すものはcommit hashではなくbranchです. そうでない場合はHEADは他のreferenceと同じくcommit hashを指しますが「detached HEAD」と警告されます.

第三章 コマンドの実態

第一章,第二章ではgitの基本的な構造を解説してきました. そこでの内容を踏まえて,改めていくつかのコマンドが何をしているのかを解説したいと思います.

add

worktreeの状態をindexに反映させます. オプション等の指定により,

  • 管理下の全てのファイルをindexに反映
  • 一部のファイルをindexに反映
  • あるファイルに施した変更の一部をindexに反映

などが出来ます.

commit

現在のindexのsnapshotを持つcommitを作成します. なお,gitの文脈において「commit」という用語は

  1. 上記で説明したcommit(snapshotを持つオブジェクト)のこと
  2. commitを作成する操作のこと

と二通りで使われます. 第一章,第二章においては一回だけ2の意味で用いましたが,その他は全て1の意味で用いています.

log

その名の通り,commitの履歴を表示します. アルゴリズム的には,現在のcommitからparent commitを辿っているに過ぎません. なお,SourceTreeのような表示にしたい場合以下のオプションとともに用いるのがおススメです. git log --oneline --graph --decorate --branches --remotes 私はこれをglとしてエイリアスを貼っています.表示結果は例えば以下のようになります.

$ git log --oneline --graph --decorate --branches --remotes
*   83c7494 (HEAD -> master) Merge branch 'develop'
|\  
| * bf290e8 (develop) poyo
* | 00c5833 just copied
|/  
* 670f4a6 yoyoyo
* 784fd6a increment
* 597fcf9 create po
* f020336 initial commit

Windowsだと「\」が「¥」になるので辛いです.

checkout

checkoutは,「worktreeの一部または全部をあるcommitの状態に変更する」コマンドです. git checkout ${brahch}という用法で最もよく目にすると思います. よく「branchを切り替える」と説明されますが,今までのことを踏まえると,この場合checkoutは「worktree全体をあるbranchの指すcommitのsnapshotに変更し,HEADをそのcommitまで変更する」という操作を行っています. また,git checkout ${commit hash} ${file}という用法もあり,これは指定したファイルを指定したcommitの状態に戻します.こちらは若干マイナーですが,むしろ定義に近い操作です.

その他,git checkout -b ${new_branch_name}という用法もあります. これは同じcommitを参照するbranchを新しく作成します.

merge

図解しているサイトを参照していただけると,merge,cherry-pick,rebaseの動作についてスムーズに理解できるかと思います. git merge ${commit hash}とすることで現在のcommitと指定したcommitの両方の状態を統合したcommitを作成します. 正確に言えば,まず現在のcommitと指定したcommitの分岐元(最も新しい共通の祖先)となるcommitを探します.分岐元のcommitと現在のcommitのdiff,分岐元のcommitと指定したcommitのdiffをそれぞれ取り,共存可能であればそれらを取り込みます. 一方,現在のcommitと指定したcommitで,あるファイルについて異なるdiffが出てしまうと競合が起こり得ます.これは手動で解決するかgit checkout --oursなどのコマンドを用いて解決するほかありません.

なお,余談ではありますがmergeによって作成されたcommit (merge commit)は親commitを二つ持ちます.

cherry-pick

図解しているサイトを参照していただけると,merge,cherry-pick,rebaseの動作についてスムーズに理解できるかと思います. git cherry-pick ${commit hash}とすることで特定のcommitの内容を現在のbranch上に適用できます.mergeとの違いを少々強引に説明すると,歴史を考慮するのがmerge,考慮しないのがcherry-pickです. cherry-pickは特定のcommitと同じsnapshotを持つcommitを現在のbranchの先頭に続けて作成しようとしますが,mergeでは上記の通り共通の祖先からのdiffを考慮した統合をしようとします.ですから,例えばあるbranchにおいてgit merge HEAD^とした場合はalready up to date.と言われ何も変更されませんが,git cherry-pick HEAD^とした場合はconflictします.また,mergeの場合は現在のcommitと指定したcommitの両方を親に持つのに対しcherry-pickの場合は現在のcommitしか親に持ちません.

rebase

図解しているサイトを参照していただけると,merge,cherry-pick,rebaseの動作についてスムーズに理解できるかと思います. git rebase ${commit hash}とすると,現在のcommitと指定したcommitの分岐元を指定したcommitに変更します. しかし,実はrebaseはcherry-pickの連続適用に過ぎません.

reset

git reset ${commit hash}とすることで,現在のbranchの参照先を指定commitに変更します. よく「git reset --hardをするとcommitが消える」といった誤解がありますが,commitは消えません.実は,ORIG_HEADというところに前にHEADが指していたcommitのhashが保存されています.ですから,git reset --hard ${any_commit_hash}をした後にgit reset --hard ORIG_HEADをすると元通りになります.

reflog

git reflogは,HEADの移動ログです.

$ git reflog
00c5833 HEAD@{0}: reset: moving to ORIG_HEAD
597fcf9 HEAD@{1}: reset: moving to 597fcf9
00c5833 HEAD@{2}: rebase finished: returning to refs/heads/master
00c5833 HEAD@{3}: rebase: poyopoyowa-i
670f4a6 HEAD@{4}: rebase: checkout 670f4a6

${commit hash} HEAD@${number} ${action detail} というフォーマットで表示されていきます. ですから,例えば「git reset --hardした後に一つcommitをしてしまったけどやはりresetを取り消したい」という場合,ORIG_HEADはもはや使えませんがreflogから希望のcommit hashをたどることが出来ます.

発展編

この章の内容はgitを利用する上でほとんど必要ありませんので,こたつでみかんでも剥きながら流し読みしていただければと思います.

git object

実は,gitのcommitやsnapshotは全てgit objectと呼ばれるものから成っています.例えば,あるcommitのhashを用いてgit catfile -p ${commit hash}と打ってみてください. すると,以下のようにtreeやparentという表示とともにhash値が表示されると思います.

$ git cat-file -p 83c7494dcbf4c01cae5977ef47b08921fc7077bd
tree e8e20230a07642ef8e1d1905c4b4133f6bfce750
parent 670f4a6f2101192e5f39fe7c94a8504e374d1a55
author Namazu <${email address}> 1577514290 +0900
committer Namazu <${email address}> 1577516327 +0900

poyopoyowa-i

treeはそのgit repositoryのrootディレクトリを示すgit objectで,parentはそのcommitの親commitのhashです. treeのhash値についてgit cat-file -p ${tree hash}とするとそのディレクトリに含まれているディレクトリ(tree)やファイル(blob)が表示されます.これらにも同じようにhash値が振られています.

$ git cat-file -p e8e20230a07642ef8e1d1905c4b4133f6bfce750
100644 blob 31a6b51d9cc6fda577d58b91ba50dc8626c865e4    po.txt
100644 blob 2df961d8030ca4360795e9608c756bdbcbc190b5    yo.txt

.gitディレクト

.gitディレクトリの中にはそのrepositoryを管理するために必要な情報が全て含まれています. ls .gitとやってみると,

COMMIT_EDITMSG
HEAD
ORIG_HEAD
config
description
hooks
index
info
logs
objects
refs

といった内容が表示されます. HEADの中身やrefs/headsの中身,objectsなどを通常のcatコマンドなどで覗いてみると面白いかもしれません.

鉞の飛ばし先

鉞歓迎いたします.正確な情報を発信したいので,誤りを見つけた場合ご指摘いただけますと幸いです. こちらの記事へのコメントまたはTwitterにて@blonde_namazu宛にお願いいたします.

参考文献

基本的にGit Documentationに従いつつ,他のサイトも理解の助けとして参考にさせていただきました.


  1. 実は,「管理下にある」の定義は少々複雑です.「.gitと同じ階層かその下の階層にある」かつ「.gitignore」されていないかつ「一度でもcommitされたことがある」と言えるかと思います.

  2. しかしcommitする度に管理下の全てのファイルをコピーしていては容量が大きくなりすぎてしまいます.

  3. root commit,すなわち最初のcommitには親が存在しません.また,merge commitには2個以上のparent commitが存在します.merge commitの親が3個以上になる場合に興味を持ったあなたは「merge strategy」で検索検索ぅ↑↑🐙

gitのお話を書きました

qiita.com

研究室とかで最近gitで事故った時の直し方とかを聞かれる機会が増えたので,一度まとめておきたいなと思いちょっとだけQiitaに書いてみました. 最近アウトプットあまり出来ていないのでリハビリ程度の感覚です.

もし「gitある程度使えるようになったけど,いまだに何してるかよくわからなくて気持ち悪い」という人が居ましたら是非読んでみてください.

はてなサマーインターン2019に参加してきました

はてなサマーインターン2019に参加してきました。はてなIDid:blondenamazuです。 京都で一ヶ月間非常に濃密な時間を過ごしてきたので書きたいことはたくさんありますが、参加記なので時系列順にまとめたいと思います。 また、2020年度以降はてなサマーインターンへの参加を考えてこのブログを読んでいる方向けにカリキュラムの詳細な紹介もしています。(2019年度と異なる可能性はありますが)

はてなサマーインターン応募〜面接

私は大学院修士一年生なので、今年の夏こそはちゃんとインターン参加するぞ〜と意気込み色々な企業のインターンを探していました。 その過程で東工大で学内修士一年生向けに開催されたインターンシップ説明会ではてなサマーインターンを知りました。

企業ごとにインターンシップのプログラムは様々ですが、全体一ヶ月間で前半二週間講義/後半二週間開発というカリキュラムを組んでいるのは私の知る限りはてなだけでした。 特に講義パートを設けている点が非常に珍しく、内容もWeb開発やインフラ、デザイン、企画など様々なテーマが充実しているのが特色だと思います。 私はWeb系の開発をしてみたかったものの自分では取り組めていなかったので、この機会にと思い応募を決めました。

そうして技術課題選考、その後面接という形でした。 面接では私がサークルで取り組んできたゲーム制作や競技プログラミングなどの話に耳を傾けていただきました。 いくつかのインターンシップの選考面接を受けた中で、最も話しやすく緊張もせずに受けられた面接でした。

午前中に面接が終了し、正午ごろには合格の連絡が来ました。 ICPC国内予選が午後にあったのでバタバタしている中でしたが、嬉しくてめっちゃテンションが上がったのを覚えています。 おかげで国内予選も頑張れました。

前半講義パート

前半の講義パートでは、毎日社員の方々が持ち回りで講義をしてくださいます。 前半のスケジュールは基本的に

時刻 内容
10:30-11:00 出社・アンケート記入
11:00-13:00 講義
13:00-14:00 昼休み
14:00-19:00 課題
19:00 退社

でした。

一週目の講義内容はGo言語によるWebアプリケーション開発、DBについて、セキュリティについてなどWeb開発全般について必要な知識を身に付けるものです。 一週目の課題は事前に与えられた最低限の機能を備えたWebアプリケーションに対して、その日の講義に関連した提示された変更を加えていくというものです。課題は毎日与えられて、翌日11時までにPRで提出するというものです。

二週目の講義はインフラやデザイン、企画などの内容です。 二週目は毎日与えられる課題はなく、金曜日の成果発表に向けて各自で機能開発をしていきます。

元々のアプリケーションはシンプルなダイアリーでしたが、私は小説投稿サイトに改造しました。 UXを意識した開発をし、ほたて*1では14票で3位を獲得しました!

後半チーム開発パート

後半の三、四週目の配属チームは前半過程の最後に決まります。 二週目の木曜日にid:motemenさんより無事全員前半過程審査通過の連絡があり、金曜日の面談でid:taira1999120さんとともにアプリチームへの配属が決定しました。

後半過程のスケジュールもほとんど前半過程と似ていて、

時刻 内容
10:30 出社
10:30-13:00 チーム開発
13:00-14:00 昼休み
14:00-19:00 チーム開発
19:00 退社

初日は同期のid:taira1999120さん、メンターのid:kouki_danさん、デザイナーのid:n-sugaさんとともに後半二週間での目標設定についてずっと話し合っていました。 結果、私がAndroidid:taira1999120さんがiOSで同じ機能を実装することにしました。インターンで両OS開発するのは初めてらしいですが、実際かなり大変でした。

二日目から本格的に開発に取り掛かったのですが、マルチモジュール開発についてあまり詳しくなかったので変なところにコードを書いて怒られていたりしました。それはそう。 また、NavigatorやData Binding、Injectionなど自分でAndroid開発をするときに逃げてきたところと真面目に向き合いました。 あとMVPからMVVMへの移行中で両方の実装が見比べられたりしたのは面白かったです。

四週目前半までわからん〜って言いながらずっとid:kouki_danさんやid:takuji31さんに泣きつく仕事をしていました。 最後の二日間くらいは最終成果発表に向けてまだやることが無限にあることに気づいて絶望しつつ、作業には慣れてきたので割とノリノリで作業していました。この二日間は一番大変でしたが、一番楽しい時間でもありました。

しかし最終成果発表にリリースが間に合わず、Androidはクライアント企業様の動作確認待ち、iOSAppleの審査待ちでした。

…となる予定でしたが、アプリチームの発表開始5分前くらいにクライアント企業様からOKが来てAndroidは100%リリースされました。あの瞬間だけは多分私を中心に世界が回っていたと思います。

ほたてによる投票の結果は5チーム中4位でした. やはり機能がきちんとリリースできてユーザーからのフィードバックまで受けられたチームと比較すると見劣りしてしまいましたが,票数で見ると30票以上も獲得できたのはとても嬉しいです.

成果物

前半過程成果物 

f:id:blondenamazu:20190913163824p:plain
前半過程成果物

小説投稿サイトを作りました。 画面間の移動が自然に行えることや書き手が困らないようなUIを目指しました。 また、ログイン情報を監視して記事の作成の可否を決めたりしました。

小説を書く/読むときに左に他のエピソードの一覧が見えるようにしたのがこだわりポイントです。

後半過程成果物

f:id:blondenamazu:20190913165513p:plain:w600

f:id:blondenamazu:20190913164909p:plain:w300f:id:blondenamazu:20190913164932p:plain:w300

コミックDAYSのアプリの布教機能を強化しました。

布教(シェア)された数に応じてユーザーにリコメンドする機能などを考案し、実装を行いました。 本当はこれ以降のアップデートも考案していてクライアント企業様からもOKが出ていたのですが実装が間に合わず無念でした。 id:kouki_danさんやid:takuji31さん、id:n-sugaさんなどが実装してくださることを楽しみにしています!!

最後に

私は普段東京暮らしなのでインターン参加前は京都で一ヶ月過ごすのに不安もありましたが、インターン最終日の今となってはもはや東京に帰りたくないくらい快適に過ごせました。 これも長きに渡る準備の末暖かくインターン生として受け入れてくださった社員の方々のおかげだと思います。

この記事の読者にははてなサマーインターンへの参加を検討している方もいると思いますが、インターンを大事にしていただける社風や社員の方々からのサポートの手厚さなど魅力的な点は非常に多いです。逆にデメリットは頑張って考えても思いつかないくらいなので、一ヶ月間京都に来られるならば是非応募してみてください!

P.S.

後ほどインターンシップにまつわる話のうち、ここに書ききれなかった部分を書きたいと思います(生活面など

*1:はてなで日常的に用いられている社内投票システム