TECH PLAY

株式会社スタンバイ

株式会社スタンバイ の技術ブログ

64

概要 既存のプロジェクトで開発しているとComposeUIを導入したくてもすでに xml でレイアウトを組んでいるので導入が難しいと思います。 そんな時に便利なのが xml とComposeUIを共存させる方法です。 とりあえずComposeUIをやってみたいというプロジェクトには簡単に導入できるのでおすすめです。 ListのところのみComposeUIで実装するイメージを例とします。 準備 build.gradleに依存関係を追加するだけです。 その他Composeに必要な依存関係は下記の build.gradle を参照してください。 implementation 'androidx.activity:activity-compose:1.4.0' Activityで実装する場合 レイアウトファイルのComposeUIを実装したいところに <androidx.compose.ui.platform.ComposeView を追加します。 <androidx . constraintlayout . widget . ConstraintLayout xmlns : android = "http://schemas.android.com/apk/res/android" xmlns : app = "http://schemas.android.com/apk/res-auto" xmlns : tools = "http://schemas.android.com/tools" android : layout_width = "match_parent" android : layout_height = "match_parent" tools : context = ".MainActivity" > <TextView android : id = "@+id/header" android : layout_width = "wrap_content" android : layout_height = "wrap_content" android : padding = "10dp" android : text = "こんにちは" app : layout_constraintBottom_toTopOf = "@+id/compose_view" app : layout_constraintLeft_toLeftOf = "parent" app : layout_constraintTop_toTopOf = "parent" /> <androidx . compose . ui . platform . ComposeView android : id = "@+id/compose_view" android : layout_width = "match_parent" android : layout_height = "0dp" app : layout_constraintBottom_toTopOf = "@+id/footer" app : layout_constraintLeft_toLeftOf = "parent" app : layout_constraintRight_toRightOf = "parent" app : layout_constraintTop_toBottomOf = "@+id/header" /> <LinearLayout android : id = "@+id/footer" android : layout_width = "match_parent" android : layout_height = "60dp" android : background = "@color/purple_200" android : orientation = "vertical" app : layout_constraintBottom_toBottomOf = "parent" app : layout_constraintLeft_toLeftOf = "parent" app : layout_constraintRight_toRightOf = "parent" app : layout_constraintTop_toBottomOf = "@+id/compose_view" > <TextView android : layout_width = "match_parent" android : layout_height = "match_parent" android : textSize = "26sp" android : gravity = "center" android : text = "Footer" /> </LinearLayout> </androidx . constraintlayout . widget . ConstraintLayout> 続いてActivityでは、ComposeViewをレイアウトファイルから取得し、 setContent(content: @Composable () -> Unit) にComposeを実装していきます。これだけです。 class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super .onCreate(savedInstanceState) setContentView(R.layout.activity_main) title = "MainActivity" findViewById<ComposeView>(R.id.compose_view).apply { setContent { MainContent() } } } ・・・・ Fragmentで実装する場合 Fragmentの場合もActivityと同様でレイアウトファイルに ComposeView を追加し、 Fragment内で ComposeView を取得し同じように実装します。 class SampleFragment: Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { val view = inflater.inflate(R.layout.fragment_sample, container, false ).apply { findViewById<ComposeView>(R.id.compose_view).apply { // lifecycleに沿ってComposeViewを自動で破棄します(LifecycleOwnerが不明の場合DisposeOnViewTreeLifecycleDestroyed) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { MainContent() } } } return view } ・・・ ちなみにDataBindingを使う場合 class SampleFragment2 : Fragment() { private var _binding: FragmentSample2Binding? = null private val binding get () = _binding !! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentSample2Binding.inflate(inflater, container, false ) binding.composeView.apply { // lifecycleに沿ってComposeViewを自動で破棄します(LifecycleOwnerが不明の場合DisposeOnViewTreeLifecycleDestroyed) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { MainContent() } } return binding.root } ・・・ 全ソース公開 MainActivity package com.example.sampleapplication import androidx.appcompat.app.AppCompatActivity import android.os.Bundle import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.unit.dp class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super .onCreate(savedInstanceState) setContentView(R.layout.activity_main) title = "MainActivity" findViewById<ComposeView>(R.id.compose_view).apply { setContent { MainContent() } } } @Composable fun MainContent() { MaterialTheme { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { repeat( 100 ) { i -> ListItem(i) { Toast.makeText( applicationContext, "Button $i Click!!" , Toast.LENGTH_SHORT ).show() } } } } } @Composable fun ListItem(index: Int , onClickListener: () -> Unit ) { Button( onClick = { onClickListener() }, modifier = Modifier.padding( 10 .dp) ) { Text(text = "サンプルボタン $index " ) } } } SampleFragment package com.example.sampleapplication import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment class SampleFragment: Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { val view = inflater.inflate(R.layout.fragment_sample, container, false ).apply { findViewById<ComposeView>(R.id.compose_view).apply { // lifecycleに沿ってComposeViewを自動で破棄します(LifecycleOwnerが不明の場合DisposeOnViewTreeLifecycleDestroyed) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { MainContent() } } } return view } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super .onViewCreated(view, savedInstanceState) requireActivity().title = "SampleFragment" } @Composable fun MainContent() { MaterialTheme { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { repeat( 100 ) { i -> ListItem(i) { Toast.makeText( activity?.applicationContext, "Button $i Click!!" , Toast.LENGTH_SHORT ).show() } } } } } @Composable fun ListItem(index: Int , onClickListener: () -> Unit ) { Button( onClick = { onClickListener() }, modifier = Modifier.padding( 10 .dp) ) { Text(text = "サンプルボタン $index " ) } } } SampleFragment2 package com.example.sampleapplication import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import com.example.sampleapplication.databinding.FragmentSample2Binding class SampleFragment2 : Fragment() { private var _binding: FragmentSample2Binding? = null private val binding get () = _binding !! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentSample2Binding.inflate(inflater, container, false ) binding.composeView.apply { // lifecycleに沿ってComposeViewを自動で破棄します(LifecycleOwnerが不明の場合DisposeOnViewTreeLifecycleDestroyed) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { MainContent() } } return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super .onViewCreated(view, savedInstanceState) requireActivity().title = "SampleFragment2" } override fun onDestroyView() { super .onDestroyView() _binding = null } @Composable fun MainContent() { MaterialTheme { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { repeat( 100 ) { i -> ListItem(i) { Toast.makeText( activity?.applicationContext, "Button $i Click!!" , Toast.LENGTH_SHORT ).show() } } } } } @Composable fun ListItem(index: Int , onClickListener: () -> Unit ) { Button( onClick = { onClickListener() }, modifier = Modifier.padding( 10 .dp) ) { Text(text = "サンプルボタン $index " ) } } } activity_main <? xml version = "1.0" encoding = "utf-8" ?> <androidx . constraintlayout . widget . ConstraintLayout xmlns : android = "http://schemas.android.com/apk/res/android" xmlns : app = "http://schemas.android.com/apk/res-auto" xmlns : tools = "http://schemas.android.com/tools" android : layout_width = "match_parent" android : layout_height = "match_parent" tools : context = ".MainActivity" > <TextView android : id = "@+id/header" android : layout_width = "wrap_content" android : layout_height = "wrap_content" android : padding = "10dp" android : text = "こんにちは" app : layout_constraintBottom_toTopOf = "@+id/compose_view" app : layout_constraintLeft_toLeftOf = "parent" app : layout_constraintTop_toTopOf = "parent" /> <androidx . compose . ui . platform . ComposeView android : id = "@+id/compose_view" android : layout_width = "match_parent" android : layout_height = "0dp" app : layout_constraintBottom_toTopOf = "@+id/footer" app : layout_constraintLeft_toLeftOf = "parent" app : layout_constraintRight_toRightOf = "parent" app : layout_constraintTop_toBottomOf = "@+id/header" /> <LinearLayout android : id = "@+id/footer" android : layout_width = "match_parent" android : layout_height = "60dp" android : background = "@color/purple_200" android : orientation = "vertical" app : layout_constraintBottom_toBottomOf = "parent" app : layout_constraintLeft_toLeftOf = "parent" app : layout_constraintRight_toRightOf = "parent" app : layout_constraintTop_toBottomOf = "@+id/compose_view" > <TextView android : layout_width = "match_parent" android : layout_height = "match_parent" android : textSize = "26sp" android : gravity = "center" android : text = "Footer" /> </LinearLayout> </androidx . constraintlayout . widget . ConstraintLayout> fragment_sample <? xml version = "1.0" encoding = "utf-8" ?> <androidx . constraintlayout . widget . ConstraintLayout xmlns : android = "http://schemas.android.com/apk/res/android" xmlns : app = "http://schemas.android.com/apk/res-auto" xmlns : tools = "http://schemas.android.com/tools" android : layout_width = "match_parent" android : layout_height = "match_parent" > <TextView android : id = "@+id/header" android : layout_width = "wrap_content" android : layout_height = "wrap_content" android : padding = "10dp" android : text = "こんにちは" app : layout_constraintBottom_toTopOf = "@+id/compose_view" app : layout_constraintLeft_toLeftOf = "parent" app : layout_constraintTop_toTopOf = "parent" /> <androidx . compose . ui . platform . ComposeView android : id = "@+id/compose_view" android : layout_width = "match_parent" android : layout_height = "0dp" app : layout_constraintBottom_toTopOf = "@+id/footer" app : layout_constraintLeft_toLeftOf = "parent" app : layout_constraintRight_toRightOf = "parent" app : layout_constraintTop_toBottomOf = "@+id/header" /> <LinearLayout android : id = "@+id/footer" android : layout_width = "match_parent" android : layout_height = "60dp" android : background = "@color/purple_200" android : orientation = "vertical" app : layout_constraintBottom_toBottomOf = "parent" app : layout_constraintLeft_toLeftOf = "parent" app : layout_constraintRight_toRightOf = "parent" app : layout_constraintTop_toBottomOf = "@+id/compose_view" > <TextView android : layout_width = "match_parent" android : layout_height = "match_parent" android : gravity = "center" android : text = "Footer" android : textSize = "26sp" /> </LinearLayout> </androidx . constraintlayout . widget . ConstraintLayout> fragment_sample2 <? xml version = "1.0" encoding = "utf-8" ?> <layout xmlns : android = "http://schemas.android.com/apk/res/android" xmlns : app = "http://schemas.android.com/apk/res-auto" xmlns : tools = "http://schemas.android.com/tools" > <androidx . constraintlayout . widget . ConstraintLayout android : layout_width = "match_parent" android : layout_height = "match_parent" > <TextView android : id = "@+id/header" android : layout_width = "wrap_content" android : layout_height = "wrap_content" android : padding = "10dp" android : text = "こんにちは" app : layout_constraintBottom_toTopOf = "@+id/compose_view" app : layout_constraintLeft_toLeftOf = "parent" app : layout_constraintTop_toTopOf = "parent" /> <androidx . compose . ui . platform . ComposeView android : id = "@+id/compose_view" android : layout_width = "match_parent" android : layout_height = "0dp" app : layout_constraintBottom_toTopOf = "@+id/footer" app : layout_constraintLeft_toLeftOf = "parent" app : layout_constraintRight_toRightOf = "parent" app : layout_constraintTop_toBottomOf = "@+id/header" /> <LinearLayout android : id = "@+id/footer" android : layout_width = "match_parent" android : layout_height = "60dp" android : background = "@color/purple_200" android : orientation = "vertical" app : layout_constraintBottom_toBottomOf = "parent" app : layout_constraintLeft_toLeftOf = "parent" app : layout_constraintRight_toRightOf = "parent" app : layout_constraintTop_toBottomOf = "@+id/compose_view" > <TextView android : layout_width = "match_parent" android : layout_height = "match_parent" android : textSize = "26sp" android : gravity = "center" android : text = "Footer" /> </LinearLayout> </androidx . constraintlayout . widget . ConstraintLayout> </layout> build.gradle plugins { id 'com.android.application' id 'kotlin-android' } android { compileSdk 31 defaultConfig { applicationId "com.example.sampleapplication" minSdk 23 targetSdk 31 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile( 'proguard-android-optimize.txt' ), 'proguard-rules.pro' } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } dataBinding { enabled = true } buildFeatures { compose true } composeOptions { kotlinCompilerExtensionVersion = "1.1.0-rc02" } kotlinOptions { jvmTarget = "1.8" } } dependencies { implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.appcompat:appcompat:1.4.0' implementation 'com.google.android.material:material:1.4.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.2' implementation 'androidx.compose.ui:ui:1.0.5' implementation 'androidx.activity:activity-compose:1.4.0' implementation 'androidx.compose.material:material:1.0.5' testImplementation 'junit:junit:4.+' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' } スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
情報を可視化するにあたっての背景 スタンバイにおいて、データを集計し提供するためのチームというのができたばかりであり、集計したデータに対して組織だって管理、改善するという仕組みがまだない状態でした。そこでまず、チーム内で管理しているデータが、どこからどの程度利用されているかという利用実態を可視化し、行動指針や評価におけるひとつの指標として使えるようにしようと考えました。 目的 利用状況の可視化の主な目的は以下の2つです。 データの利活用という観点で、 定量 的な指標を設ける アクセスログ による アドホック な調査ができる状態にする データを管理するにあたり、作業方針の決定や作業の影響度合いを知る指標がチームにおいて存在しませんでした。自分たちの作業を 定量 的に評価できないことは、運用におけるコミュニケーションが個人の感覚に依存した状態となり、データの利活用状況について正しく理解できないリスクがあります。これに対して、まずはチームで管理しているデータについて CloudTrail によって集められている アクセスログ を集計、可視化し、データの利用状況についてチーム内で統一の評価ができる状態にします。 また、CloudTrail のログに対して、いつでもクエリできる状態にすることで、データに対する調査の手段として CloudTrail のログというのが存在する状態にします。 取り出したい数字 可視化項目 数値をどう使うか 総アクセス数の推移 社内のデータ利活用が増えているかをみる判断材料 ユーザ数の推移 社内でのデータ利活用が増えているかをみる判断材料 アクセスの多い バケット のランキング バケット の重要度合いをみる アクセスの多いユーザのランキング 主となる利用者が誰なのかをみる アクセスの多いテーブルのランキング テーブルの重要度合いをみる バケット 単位のアクセスの多いユーザのランキング バケット 単位で主となる利用者が誰なのかをみる テーブル単位のアクセスの多いユーザのランキング テーブル単位で主となる利用者が誰なのかをみる データドリブンによる開発・改善が会社の方針でもあるため、チームで集計したデータがたくさんの社員にたくさん利用される状態が望ましいと考えています。そのため、データの参照数やユーザの数といった数値を主に集計します。これらの数値が数ヶ月を対象とした期間で見られる ダッシュ ボードを作ることをイメージしています。 可視化するまでの手順 1. CloudTrail のログデータをテーブル化する 私が配属するときには、すでに CloudTrail が有効化されており、特定の S3 バケット にデータが溜まっていた状態だったため、CloudTrail を有効化してログをためる手順につきましては割愛させていただきます。CloudTrail の設定手順につきましては、 公式の資料 をご参考ください。 公式の資料 を参考に、Athena から external テーブルを作成します。このテーブルから数値を集計するわけですが、 パーティション の設定がないと、集計のたびに全てのデータを参照しにいってしまい時間とコストがかかるため「 パーティション 射影を使用した Athena での CloudTrail ログ用テーブルの作成」の内容を参考に CREATE TABLE 句を作成することをお勧めします。 ここで注意ですが、テーブル定義の時に カラム名 を変更しないようにしましょう。キャメルケースで書かれた カラム名 が input format で指定している com.amazon.emr.cloudtrail.CloudTrailInputFormat のどのタイプであるかを識別するため、 カラム名 を変更すると正常にデータを参照できなくなります。 また、Date 型で パーティション を切ることを試してみましたが、スラッシュ区切りの文字列を Date型として取り扱うことができなかったため、公式の資料にある通り、external テーブルの段階では String の型で パーティション を切っています。 2. CloudTrail のデータから取りたい数値を日毎に集計する 作成した external テーブルに対して、取りたい数値を得るクエリを直接実行してみても良いのですが、数日を対象としたクエリでも結果を得られるまで5分以上かかる状況だったため、日毎に バケット 、テーブル、ユーザについてまとめあげたテーブルを別途用意し、そのテーブルに毎日レコードを追加するようにしました。 テーブル定義 CREATE TABLE cloud_daily_metrics ( user_name String, bucket_name String, table_name String, count Bigint ) PARTITIONED BY (date date) STORED AS PARQUET LOCATION "s3://bucket/prefix/" 作成したテーブルにデータを INSERT するクエリ(日付はサンプルになります) INSERT INTO "db_name"."cloud_daily_metrics" SELECT b.user_name AS user_name, b.bucket_name AS bucket_name, b.table_name AS table_name, count(1) AS count, b.date_jst AS date FROM ( SELECT CASE WHEN userIdentity.type = 'Root' AND userIdentity.userName IS NULL THEN 'Root' WHEN userIdentity.type = 'Root' AND userIdentity.userName IS NOT NULL THEN userIdentity.userName WHEN userIdentity.type in ('IAMUser', 'SAMLUser', 'WebIdentityUser') THEN userIdentity.userName WHEN userIdentity.type = 'AssumedRole' THEN split_part(userIdentity.principalId, ':', 2) ELSE 'other' END AS user_name, cast( json_extract( json_parse( requestParameters ), '$.bucketName' ) AS varchar ) AS bucket_name, split_part( cast( json_extract(json_parse(requestParameters), '$.key') AS varchar ), '/', 1) AS table_name, date( date_parse(a.eventtime, '%Y-%m-%dT%H:%i:%sZ') at time zone 'Asia/Tokyo' ) AS date_jst FROM "db_name"."cloudtrail_external_table_name" AS a WHERE a.timestamp in ( '2021/12/20', '2021/12/21') ) AS b WHERE b.bucket_name like 'bucket-prefix%' AND b.date_jst = date '2021-12-21' GROUP BY b.user_name, bucket_name, b.table_name, b.date_jst user_name を取り出している CASE 文については、 公式の資料 を参考に、ユーザ名として有効と思える要素を取り出しております。 bucket _name, table_name は requestParameters の内容から取り出しておりますが、CloudTrail の設定などによっては他のカラムから取ってくることが適切な場合があるかもしれないため、CloudTrail のログの内容を確認した上で設定しましょう。 CloudTrail のログの タイムゾーン は UTC となっているため、INSERT するタイミングで JST に直した日付を作成しています。 JST に変換するにあたり、2日間のデータをインプットとして集計しています。 WHERE b.bucket_name like 'bucket-prefix%' こちらの条件句は、本集計対象がチームで管理する バケット を対象としていたため、 バケット 名の プレフィックス で指定しています。 3. ダッシュ ボードを作成する チームでは re:dash による ダッシュ ボード作成が主な可視化手段となっていたため、上記クエリにより日毎に集計されたテーブルから、数値を取り出すためのクエリを用意し、 ダッシュ ボードを作成します。 re:dash において作成したクエリ群(中括弧で囲まれた部分は re:dash のUIより設定できる変数となります) # 総アクセス数の推移 select date, sum(count) as AccessCount from db_name.cloudtrail_daily_metrics where date >= date '{{ start_date }}' and date <= date '{{ end_date }}' group by date order by date # ユニークユーザ数の推移 select date, count(distinct user_name) as UniqUserCount from db_name.cloudtrail_daily_metrics where date >= date '{{ start_date }}' and date <= date '{{ end_date }}' group by date order by date # アクセスの多いバケットのランキング select bucket_name as BucketName, sum(count) as AccessCount from db_name.cloudtrail_daily_metrics where date >= date '{{ start_date }}' and date <= date '{{ end_date }}' group by bucket_name order by AccessCount desc # アクセスの多いテーブルのランキング select table_name, sum(count) as sum_access from db_name.cloudtrail_daily_metrics where date >= date '{{ start_date }}' and date <= date '{{ end_date }}' and table_name is not null group by table_name order by sum_access desc # データの利用者ランキング select user_name, sum(count) as sum_access from db_name.cloudtrail_daily_metrics where date >= date '{{ start_date }}' and date <= date '{{ end_date }}' group by user_name order by sum_access desc # バケット別ユーザランキング select user_name as UserName, sum(count) as AccessCount from db_name.cloudtrail_daily_metrics where date >= date '{{ start_date }}' and date <= date '{{ end_date }}' and bucket_name = '{{ bucket_name }}' group by user_name order by AccessCount desc # テーブル別ユーザランキング select user_name as UserName, sum(count) as AccessCount from db_name.cloudtrail_daily_metrics where date >= date '{{ start_date }}' and date <= date '{{ end_date }}' and table_name = '{{ table_name }}' group by user_name order by AccessCount desc 作成したクエリをもとに、 ダッシュ ボードを作成します。 ダッシュ ボードからの気付き 作成したデータがちゃんと利用されているのかという疑問に対して、営業日に20〜30人程度から利用の記録があるので、全く利用されていないということはありませんでした。日毎のアクセス数が2,500万を超えているのは、システム的な集計のアクセスもカウントしていることもありますが、CloudTrail のログがファイルごとに発生するため、1回の集計で参照するファイルの数が多いと数値が高くなりやすいです。アクセス数の数値自体はあまり意味を持ちませんが、長期的な傾向を見ることで、データの利用が増えているかどうかをみる指標としては使えるものと思われます。 ユーザのランキングの中で、個人で複数のアカウントを使用しているようなケースも確認でき、このアカウントは開発においてしかたなく用意したもので、今後整理しなければならないといった 潜在的 な課題を浮き彫りにさせることができました。 さらに意味のある ダッシュ ボードにするために 現在は個人アカウントとシステムアカウントが内混ぜの状態で集計しているため、アクセス数の多くはシステムアカウントが占めており、個人アカウントのアクセスについての情報が埋もれてしまっています。そのため、アクセス数については個人アカウントとシステムアカウントを分離してカウントすることで、データ利用者からのアクセスについて確かな情報を得られる可能性があります。 re:dash によるデータ参照のように、社内からデータのアクセスは、特定の可視化ツールを使用しているケースもあり、可視化ツールからの利用は単一のアカウントからの参照とみなされている箇所があります。そのため、より詳細に可視化ツールから誰がアクセスしているのかというのを調べる(ドリルダウン)するためには、可視化ツール側のログか何かを調べる必要が出てきます。 ログに落ちたものをカウントしているため、参照がない(アクセスが0)という情報がこの ダッシュ ボードからはわかりません。参照のないデータは残し続けると余計なコストとなるため削除するといったアクションが求められますが、この ダッシュ ボードからでは「利用が少ない」を読み取ることまでしかできません。既存のテーブル一覧とつき合わすような形で「アクセスのないテーブル」として一覧化することが望ましいです。 おわりに 「この数値は売り上げに直接関係するのか?」と聞かれれば答えはノーです。そのため、こういった内部利用における数値の可視化というのは重要度が上がりにくいものと思います。データのマネジメントを行うようなチームの功績というのは、会社への貢献のしかたが間接的になりやすく、会社の掲げるKPIに直結することは稀でしょう。そのため、チームで追うべき数字というのも明確にしづらく、チームのモチベーションを維持することが困難という課題もよく聞きます。 今回私が担当させていただいた CloudTrail からの数値の可視化は、データのマネジメントを行う上でのひとつの指標にすぎませんが、これがあることで今後の施策における影響度合いの確認や、他のチームへの説明における具体的な評価値として用いることができます。データドリブンな会社として、よりデータの専門部隊がデータドリブンな状態として活動できるよう、客観的に評価できる仕組みを整えていければと考えております。客観的に評価できる仕組みを整えていければと考えております。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
- はじめに - この記事で得られること - ABテスト導入前 - この章でのポイント - 仮説から検証までのプロセス導入と大量の見立て - 定性的な観点での優先度づけ - 定量的な分析による優先度づけ - この章でのポイント - ABテストツール導入 - この章でのポイント - 数値と想定(主観)との乖離の連続 - この章でのポイント - 改善できる指標は何か - この章のポイント - チームでプロセスを高速改善 - この章のポイント - 最後に はじめに まず結果としては約半年でKPIが130%成長を達成しました! ※画像は約半年間のKPI推移のグラフ この記事では、4月からABテストによる検証環境を持たないチームにABテストを導入し、数値改善した流れを書き記してます。 まずは、チームが改善している指標の説明をします。 チームが改善している指標は「求人クリック数/流入数」であり、プロダクト全体では「求人クリック数」をKGIとして改善しています。 KPIツリーではこのような構造になっています。 チームで改善しているのは図の赤枠内の指標になります。 また本記事の「ABテスト」とは、以下の内容で記載しております。 A/Bテストとは、異なる2パターンのWebページを用意し、実際にユーザーに利用させて効果を比較するテストのこと。 出典: ASCII.jpデジタル用語辞典 この記事で得られること この記事で得られることは、主に3つあります。 ABテスト運用についての概要・気をつけるポイント 目的に沿った改善の方法 仮説〜検証までのプロセス事例 主にはこの3つを踏まえつつ、これまでの約半年でどのような取り組みを行ったか、時系列を交えてお伝えします。 ABテスト導入前 ABテスト導入前はとにかく、競合他社のサイトを見て差分を洗い出し、施策にしようとしていました。ここではおおよそ200程度の案が出ていたと記憶しています。 ただ、ここから2つの問題に直面します。 1つ目:「どれから実施していくべきか?が分からない」 プロダクトに施策を反映していくのはタダではありません。そこには企画、要件定義やエンジニアによる開発が存在します。 限られたリソースの中で、最大の結果を出したい。となったときに何から取り組めばいいか分からない状態でした。 2つ目:「施策をどう評価していいか分からない、続けるべきか判断できない」 プロダクトに施策を反映しても、本当にKGIに寄与しているのか?続けていいのか?が評価・判断できない状態でした。 GoogleAnalyticsなどの分析ツールを使っていましたが、果たしてKGIの向上の要因となっているのか、阻害しているのかの、定量的な評価と判断ができない状態でした。 この2つの問題を解消し、再現性を持ってKGI・KPIを伸ばしていく取り組みの模索が今年の4月からはじまりました。 この章でのポイント KPIを改善するにあたって、 施策の優先度が見えない 評価と判断を定量的にできない という状態だった。 仮説から検証までのプロセス導入と大量の見立て まず1つ目の問題である「施策の優先度が見えない」という問題の解消についてです。 優先度が見えない、つけづらい要因として、 施策が与えるKPIの改善が定量的に想定できていない 施策にかかるリソースが分からない といった課題が存在しました。 また、検討するべき施策がおおよそ200程度存在していたこともあり、定性的な観点での優先度と定量的な分析による優先度づけが必要だと考えました。 定性的な観点での優先度づけ 定量的なシュミレーションを案件ごとに実施したいですが、かなりのリソースが必要だとわかりました。 具体的にはひとつの案件あたり、2〜3時間は時間を要さないとシュミレーションできませんでした。 そこで、まずは200ある施策案のうち、どこからシュミレーションするべきか?を考えるために、以下のようなKPIを更に分解した構図を作成し、施策案がどこに該当するのかの整理からはじめました。 ※またこのタイミングで200ある案も一部、取捨選択しています。 ※便宜上、求人クリック数をCVn、求人クリックした流入をCVss、流入をSSと記載しています。 施策の分類後、競合他社の調査を元に「どのKPIが最も乖離しているか」を分析し、優先度づけました。 これにより優先度の高いKPIの施策から検証していく。という方向性が決まり、施策ごとのシュミレーションに取り掛かれます。 定量的な分析による優先度づけ 次に定量的なシュミレーションを施策ごとに行います。施策の単位は「ボタンのデザインを変える」といったものをイメージしてください。 ひとつの施策をリリースし検証するまでに、以下のようなプロセスを作成し優先度を判断しながら進めめました。 「見立て」「仕立て」は以下のように定義しています。 見立て:効率の良い案件を生み出すためのプロセス 仕立て:見立てを「仮説検証」をするためのプロセス プロセスをスピード感持って実施するため、テンプレートを事前に用意し進捗の可視化と振り返りの利便性を上げました。 これによって過去案件の参照や他部門への共有も行いやすくなります。 ※画像は見立て・仕立て時に使用するドキュメントのテンプレート この章でのポイント 競合分析とKPI優先度づけで、方針と大量の施策優先度を決めた 大量の施策を検証するために、テンプレート化やプロセスごとのチェック体制をつくった ABテストツール導入 次に問題の2つ目にあたる「評価と判断を定量的にできない」状態の解消を取り組みました。 一言で言ってしまえば、ABテストを実施できるツールを導入したという結果ですが、導入にあたっていくつか検討しました。 細かい数値部分については、取り組んでいるKPIや、どれだけの精度を求められるかといった事情もあり、一概に何が最も正しいか言及できないので割愛します。 まず導入にあたって「テスト結果によって、変更をするべき。施策を採用するべき」という基準を設けました。 合わせて、検証するときに見ておくべき指標やテスト実施での運用を以下のように定めました。 同時並行実施の基準 スピードを最優先。結果に影響が出ないと判断できるところまでで並行実施 検証期間 期限を設ける。シュミレーション時に検証期間も計算しておく。検証期間が長くかかるケースはリターン自体も少ないと判断 モニタリング観点の明確化 KGI・KPIは必ずモニタリング & 中間指標もウォッチする 成功基準 KPIの結果で判断する。KPIが変動しない結果では中間指標で判断 有意差ありの基準 一般的な水準で実施 大まかな検討・ルール化した項目が上記になります。 実際にABテストを運用しKPIを大きく改善している有識者に入ってもらい、議論を重ねた上で細かい数値や基準を制定しました。 基準を設けた上で、いよいよABテストツールを使った検討をはじめました。 【余談】ツールは何がいいの?といった点がありますが、 精度が担保できる 作業がしやすい 検証結果の分析が行いやすい といった点を踏まえて、複数ツールを検討(できるならば一度運用してみる)することが好ましいと後になって気づき、ツールの再検討を現在実施しています。 この章でのポイント 施策の結果について評価・判断を定量的に行えるよう、ルールを決めた上で運用を開始した 数値と想定(主観)との乖離の連続 ABテストを開始してみると、「いける!かなり改善するはず!」と思っていた施策がことごとくオリジナルに負けたり、また、「そこまで上がらないだろう」と思っていた施策が大きな改善になる。といった事象が発生しました。 分析を進めることでわかったのが、要因としてKGIが「求人クリック」であることが大きいのでは。という結論に至ってます。 プロダクトの種類やフェーズによって理由は様々ですが、一般的に求人は応募というマッチングを生み出すために作られると考えます。 ただし、僕らのプロダクトとフェーズでは「求人クリック」を最大化しようという設計なので、自分たちがプロダクトを触って感じるユーザー目線での目論見が外れるといったことが多々起きました。 そこでテーマ別に結果を集計したり、検証の数を増やしていくことで改善する施策の共通項や傾向を見出して、案件創出にフィードバックしていきました。 ※画像は傾向を掴むためのテーマまとめの一例 この章でのポイント 定性的に分析・想定していた有効であろう施策も、定量的に検証すると目論見と大きく外れることが多々発生した 改善できる指標は何か 傾向がつかめてくると、次は限られた期間でより多くの成果・改善を求めていきます。 改善できる指標を探すために、ABテストの活動を定量化しました。 ※ここでの「成功」はABテストの結果、オリジナルよりKPIが良くなった場合を指す まずは自分たちの行動でコントロールできる指標が何かから見ていきました。 成功率や案件あたりの改善数値はABテストしてみないと分からないので、「検証案件数」が自分たちでコントロールできると考えました。 検証案件数を分解すると、このような組み合わせで計算できることがわかります。 ページあたりの検証案件数が、自分たちの行動で最大化できる部分だと考え、先に記載した見立て→仕立てのプロセスをどれだけ多く、より速く実施できるか?を各工程ごとに検討しました。 結果としてABテストを本格的に実施する前に計画していた1週間あたりの案件数より、現在は約3倍ほどの施策を実施できる状態になりました。 いま振り返ってみて、「定量的に測れる指標を設けたこと」と「立場・役割関係なく、課題を可視化し解決にチャレンジできたこと」が良かったです。 定量的な指標を設けたことで、誰もが課題を特定できるようになり、また「誰が言ったか」ではなく、「何を言ったか」といった観点で客観的に議論・検討できる流れができました。 また、施策数が増えてくることにより、これまでABテストをしないと分からなかった成功率と相関性のある指標も見えてきました。 具体的には1本のABテストで要するパターンの数と、成功率の相関性を発見でき要件作成時にパターンの最低推奨数を組み込んだことで成功率UPに繋がりました。 (※パターン数は、ABテストなら1パターン。ABCテストなら2パターンといった数値) まとめると、自分たちがコントロールできる数値を最大化した結果、更に行動ベースで改善できる指標が出てきたといった副次的な効果も得られました。 ※図は多くの施策を実施したことで発見した指標の一例 このように指標の分解、分析からプロセス内でのルール化などを行い、KPIの最大化が徐々に上手く回り始めて、かつ再現性を持って実施できるようになりました。 そして、更に改善するスピード・確度を高めた要素としてゲーム感覚を持てたことも大きかったと考えます。 例えばですが、リリースした案件が採用される率を「打率」や、KPIに対して大きく改善した案件を「ホームラン」「ツーベースヒット」などと社内のユビキタス言語のような表現を設けました。 結果、自分たちの成果がゲーム感覚で実感できるようになったため、共有しやすく、「次もホームランを目指そう」といった具合に、小さな目標や成果を掲げやすいことも大きかったと感じます。 この章のポイント 再現性を持ってKPIを改善するために、以下の取り組みが効果的だった。 定量的に示せる指標を見つける 指標を分解して自分たちでコントロールできるところを見つける 社内言語を用いて、ゲーム感覚で数値の改善に取り組む チームでプロセスを高速改善 ここまで記載したことは誰かひとりによって推し進められた訳ではなく、前章で記載した指標の可視化・分解 → 課題の洗い出し → 課題の解決が、チームとしてかなりの速度で進んだことが要因でした。 振り返ってみれば、以下のような要素が良かったと考えています。 1週間で区切って振り返り・軌道修正をしたので、プロセスの高速改善が可能だった Miro で議論した(発言だとどうしても偏りがでるが、付箋だと意見を述べやすい、全員参加しやすい) 定量的な指標を毎週全員で振り返り・確認し、共通の目標が明示化され、自分ごととして議論できるようになった 短いスパンでの分析と、全員が当事者意識を持ったアウトプットにより、結果としてチーム単位でのプロセスの高速改善に繋がりました。 またチーム結成当初に比べて、時間を経るごとに、出てくる課題の種類がよりプロセス改善の内容になっていく様子からも、チームの成熟が進んでいることも感じました。 結果として、課題に即時対応・軌道修正ができる体制とプロセスを手に入れることができ、さらに、1ヶ月先までの取り組みと得られるであろう成果の予測が立てられるまでになりました。 この章のポイント プロセスの高速改善には、短いスパンでの振り返りが有効 Miroを使うことで偏りの無い発言と議論が実現 共通の目標を明示化することが、チーム全員の自分ごと化の手助けに 最後に 振り返ってみれば、約半年でKPIを130%以上成長させることができました! 恐らくこの半年間は怒涛の日々だったとチーム全員が感じているのではないでしょうか。 最初に決めた多くのことは、今となってみればほとんどが変わった・新たにルール化された項目ばかりです。 これまでを書き出してみても、一人でできることは限られており、目的や共通課題をチーム全員ですり合わせたからこそ、多くのアイディアや実行が果たせました。 また、最初に出した施策は200ありましたが、多くが実施されず、代わりにABテストを重ねる上で更に多くの施策案が生まれました。 そして、実際にABテストができた施策数も200を超えるところまできています。1週間で多い週では6本ABテストを実施しています。 "早く行きたければ一人で行け、遠くへ行きたければみんなで行け" (If you want to go fast, go alone. If you want to go far, go together.) ということわざがありますが、まさにその通りだと痛感する半年でした。 まだまだ多くの改善や取り組みが必要なフェーズですが、この半年で学んだことを更にブラッシュアップして、プロダクトとしてより遠くにいけるよう引き続き頑張ります。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com
アバター
(本記事は、執筆時2022年2月時点の情報です) スタンバイのプロダクト本部の行動指針「START」と「Engineering Belt」とは? 私達は、国内最大級の仕事・求人エンジン「スタンバイ」を成功させるために、常にユーザーファーストとプロダクトを中心に考えています。それを実現するため、強いProduct-drivenなTech組織を追求しています。 本記事では、スタンバイのプロダクト本部の事例を上げて、Product-drivenなTech組織の説明から、プロダクト創りに特化した行動指針「START」やエンジニア等級制度「Engineering Belt」を紹介します。 スタンバイにとってProduct-drivenなTech組織とは? 経営者のビジネスを成功させたい意思とエンジニアの専門性を高めたい意志をどのように結びつけて事業成長に生かすのか? エンジニアにとって、事業の理解度がどのようにプロとしての市場価値に繋がるか? スタートアップの文化と制度は成否にどのような影響があるか? 特に個々人の専門性を高めたいエンジニア、プロダクトが中心となる事業を成長させたい企業や組織の参考の助けとなればと考えています。 スタンバイとは? 2015年5月にビズリーチの新規事業として「スタンバイ」がリリースされ、2019年11月にZホールディングス株式会社と株式会社ビズリーチ(現ビジョナル株式会社)はスタンバイを運営するために合弁事業会社「株式会社スタンバイ」を設立しました。 スタンバイが目指しているのは、仕事探しをもっと便利にすることによって、すべての働く人に、新しい働き方の可能性に気づいていただくことです。 たとえば、今よりもっと通勤が便利になる、自分に合った時間帯を選んで働ける、自分のやりたい仕事により近づく。そういった多様な選択肢を提供することにより、働くひとの可能性が広がって行くことが、私たちの願いです。そのような願いのもと、私たちは”UPDATE WORKSTYLES「はたらく」にもっと彩りを”をミッションとして定めました。 このミッションを実現するため、独自の仕事・求人検索エンジンを提供しています。この基盤となる検索エンジンやデータ管理などは非常に専門性が高く複雑なプロダクトであるため、プロダクト・技術戦略およびドメイン知識などを基盤に持った強い組織が必要です。 プロダクト本部が目指す強いProduct-drivenなTech組織とは? 「プロダクト主導型の組織は、実際に顧客とプロダクトに焦点を定める組織です。」 1 継続的にユーザを中心に考えて、価値の高いプロダクトを生み出すため、それを実現できる環境を整備しています。 組織のビジョン 事業を成功させるため、優秀な 人 が高い 技術 力(エンジニアリングに限らず)を生かして、価値の高い プロダクト を創り続ける必要があると考えています。スタートアップであるスタンバイを成功させるため、個々人が事業を深く理解し、個々の専門性を発揮し、自らの裁量を持って実行できることが必須です。それは目指す組織と環境の状態定義に以下のように表現しています: 個々人が事業の構造を深く理解しその貢献のために考え、実行できる環境 個々人が専門性や強みを生かし新たなチャレンジや成長をし続ける環境 多様な働き方を受け入れた柔軟な組織 圧倒的な透明性、権限委譲、当事者意識による高いAgility このような組織のビジョンとあるべき姿に近づけるため、私達は行動指針を定義することにしました。詳細は次の段落で説明します。 行動指針「START」 プロダクト開発における個人とチームの行動の規範として行動指針を作成しました。 個人とチームの行動とマインドセットに関する期待を言語化したもので、「START」の5つの行動の頭文字から構成しました。 この行動指針は、日々会話の端々に出るくらいの浸透を図りたいと考えていて、月次で行っている本部内表彰の選考軸としていたり、採用・評価・育成・人材配置の観点、そして、このTech blogや Advent Calendar など社外に情報発信する際の規範としてなど、常にプロダクト本部で活動を照らす鏡として使っています。 各STARTの行動指針の観点の対象を明確するための図 「S・T・A・R・T」それぞれが示していることは次のとおりです。 Scientifc (科学的): 数字をビジネス、プロダクトの共通言語として、すべての事象や方針決定においてできる限り数字の裏付けをもとに実施され、評価され議論が行われる Technological (技術的): あらゆる課題解決において技術的解決を最優先に考え、実施、または、技術的課題解決の未来のためのソリューションを提供する Ambitious (野心的): グロースハック環境が整備され、常に野心的な戦略や目標に向かって挑戦し続ける Relevant (自分ごと化): ビジネス、技術の専門家としてプロダクトの成長を考え、ユーザにとってより最適なソリューションを提案し続ける Transactive memory (知恵最大化): 個々の専門性をリスペクトし、協力し合い、議論し、より最適な結果を導き出す さきほど採用・評価・育成・人材配置の観点で使っていることに触れましたが、具体的に次の段落で説明します。 エンジニアに特化した等級定義と評価制度(Engineering Belt) 高い技術力を追求するため、Tech DNAとマインドセット、Technologyの要素を常に意識する必要があります。そうするため、エンジニアに対して「START」の観点を使った、エンジニアに特化した等級定義を作成しました。 この等級定義を作成した目的は、エンジニアの技術力の向上、キャリアパスを明確することだけではなく、経営者を含む他職種のメンバー間との共通言語を作ることも含まれています。なぜなら、ビジョン・戦略をプロダクト開発までしっかり繋げることが、スタンバイのミッションを達成するために不可欠な武器になると考えているためです。 また、この等級定義を作成する課程で、ビジョンや行動指針を体現するためには「多様な働き方を受け入れ、当事者意識による高いAgilityを発揮し、上下関係なくお互いを助け合う精神が必要不可欠だろう」という議論を重ねました。 その結果、『技を磨き、成長し続ける(学び続ける)場』である『道場』をオマージュし、空手・拳法などの「帯」をモチーフとして「Engineering Belt」と名付けることにしました。 Engineering Beltの作り方をイメージするための概要と詳細ページ(省略) 「Engineering Belt」ではSpecialistとManagementのキャリアパスに分けて定義し、行動指針「START」個々の観点から基準と期待を明示しています。加えて、事業の成長のため、プロセス改善(例えば開発プロセスを通じてROIを高めるなど)、及びドメイン知識の獲得・啓蒙という観点を入れました。 そうすることによって、エンジニアリングレベルが事業や顧客価値への影響力に直接繋がる道筋を示し、レベル(Belt)が上がると、エンジニアリング力も上がると共にエンジニアとしての市場価値が上がることを狙い、スペシャリスト、またはジェネラリスト(マネジメント)を目指していくエンジニアに対して、それぞれの目標となる基準を言語化しました。 この「Engineering Belt」は、「START」を具体化したものなので、メンバーの目標設定や評価に使うことはもとより、採用時や人材配置時などに一貫して使っています。 最後に 今回は、私達のプロダクト開発組織が会社のミッションから組織のビジョンを定義し、それを実現するための行動指針「START」を定義し、更にそれをブレイクダウンしてメンバーの成長過程にあわせた行動基準を示す「Engineering Belt」を作成したことを紹介しました。 これらを体系化することは、事業とメンバーとの双方を共に成長させ、かつ持続可能性を獲得するために必要なツール、言ってみれば武器を(組織に)提供することだと考えています。 これらの基盤的なツールをはじめ、私達は事業の成功のための活動を行っています(例えばプロダクトロードマップ・テックロードマップ、OKR、DxCriteriaなど)これらはまた別の機会にご紹介したいと思います。 スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com https://www.leadingagile.com/2019/02/product-driven-organizationsthe-evolution-of-agile/ ↩
アバター