4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

GASとJSを使ってサイドバーで進行状況や途中結果を表示させる

Last updated at Posted at 2021-02-13

TL;DR

やたら時間のかかるGASの処理に対して、サイドバーを使って進行状況や途中結果を表示させるようにします。

大まかな方針

私が考える限り、実現方法はだいたい3通りありそうです。

  1. 計算がある程度進むたびにサイドバーを起動しなおす
  2. サイドバーの更新と計算を非同期に実行する
  3. 計算のキックをサイドバーに任せる

で試したところ、3.が一番良さげな結果でした。
ひとまず1から順に説明していきます。ここではGoogleスプレッドシート上で何かやる、という想定です。

1. 計算がある程度進むたびにサイドバーを起動しなおす

この方法は以下の記事でほぼ解説されています。

ただこれ、やってみるとわかるんですが__サイドバーが再起動するたびチラつく__んです。
単純に見づらいのもありますが、サイドバーを表示に使うだけじゃなくて、そこからコピペしたりサイドバー上で何か操作するような用途まで考えるとちょっと厳しいです。

2. サイドバーの更新と計算を非同期に実行する

なので再起動はせず、サイドバーはサイドバーで動かし、サイドバー内で表示を更新することを考えます。
つまり、以下の2つを同時にやります。

  1. GASで計算をする
  2. サイドバー上のJavascriptで計算を読み取り、表示する。

この場合当たり前ですが、表示部分はGASではなくJSでやることになります。なのでそっち側のデバッグにはブラウザのデベロッパーツールなんかを使います。

先に全部のコードを書いてしまいます。まずはGAS側。

gas1.gs
const sheet = SpreadsheetApp.getActive().getSheetByName('作業用シート');
const cell = sheet.getRange(1, 1);

// サイドバーからキックされて進捗を返す
function getProgress() {
  return cell.getValue();
}

// 実際にする計算
function calc() {
  for (let i = 0 ; i < 10 ; i++) {
    Utilities.sleep(2000);  // 何か計算してる代わり
    cell.setValue(cell.getValue() + 1);
  }
}

// 状態の初期化
function init() {
  cell.setValue(0);
}

// スタート
function start() {
  // 初期化
  init();

  // サイドバー表示
  const html = HtmlService.createHtmlOutputFromFile('サイドバー');
  SpreadsheetApp.getUi().showSidebar(html);

  // 計算  
  calc();
}

こっちはサイドバーのHTMLです。

サイドバー.html

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <script>
      function run() {
        id = setInterval(function() {
          google.script.run.withSuccessHandler(function(response) {
            if (response >= 10) {
              clearInterval(id);
              document.getElementById("progress").innerHTML = '終了';
            } else {
              document.getElementById("progress").innerHTML = response.toString();
            }
          }).getProgress();
        }, 1000);
      }
    </script>
  </head>
  <body onload="run()">
    現在: <span id="progress"></span>
  </body>
</html>

実行途中のスナップショット。
スクリーンショット 2021-02-13 13.30.25.png

非同期なのでたまにシート上の内容とサイドバーの表示がずれますが、それはそういうものということで。
以下はコードの説明です。

GAS側

計算本体はこのcalc()です。ここにやりたいことを書きます。


// 実際にする計算
function calc() {
  for (let i = 0 ; i < 10 ; i++) {
    Utilities.sleep(2000);  // 何か計算してる代わり
    cell.setValue(cell.getValue() + 1);
  }
}

一方で、サイドバーから叩かれたら途中結果を返すのがこのgetProgress()です。

// サイドバーからキックされて進捗を返す
function getProgress() {
  return cell.getValue();
}

そして、すべての計算の開始はここです。サイドバーを起動しつつ、計算を開始します。

// スタート
function start() {
  // 初期化
  init();

  // サイドバー表示
  const html = HtmlService.createHtmlOutputFromFile('サイドバー');
  SpreadsheetApp.getUi().showSidebar(html);

  // 計算  
  calc();
}

サイドバー側

サイドバー側はちょっとややこしいので、多めに説明します。

サイドバーからGASを叩く仕組み

サイドバー内のJavascriptからGASのfoo()を叩きたい場合、google.script.run.foo()と書けば可能です。
ただし、この関数はfoo()の返り値を返しません。返り値を受け取る場合、withSuccessHandler()にハンドラとなるコールバック関数を登録して、そのコールバックの引数として返り値を受け取ります。
つまり、コールバック関数function success(response) {...}みたいなものを用意したとすると、google.script.run.withSuccessHandler(success).foo()と呼び出すことでsuccess()の引数にfoo()の結果が返ります。

この例ではGAS側のgetProgress()を使って状況を取得したいので、google.script.run.withSuccessHandler(success).getProgress() を呼び出すことになります。

setInterval()で定期的に状況をチェックする

setInterval(bar, time) とやればtime(ミリ秒単位)ごとにbarがキックされます。
これと、さっきのgoogle.script.runを組み合わせて、定期的にGAS側のgetProgress()を叩き、状況をチェックします。
なお処理終了が確認できたらclearInterval()を叩きます。使用方法は上のコードを参照。

問題点とかハマりポイントとか

この方法で済んでしまうことも多々あると思うのですが、1つだけ注意点があります。
google.script.runで叩かれる関数は__別プロセスで走ります__。つまり、GAS側と同じコードを叩いても__GAS側の計算途中の状態をgoogle.script.runで叩いても直接読み取ることはできません__。
ただし__スプレッドシートの状態は共有できます__。なので、GAS側で途中の状態を返す関数(この例ではgetProgress())はスプレッドシートから現在の計算状態を読み取る必要があります。

計算の状態がスプレッドシートにすぐ出ないか、出てもぱっと読み取れない場合、途中経過の値をどこか専用の別シートに書き出す方法もありますが、__それできるんなら別にサイドバー要らない__って場合も多いです。

3. 計算のキックをサイドバーに任せる

2の方法だとうまくハマらない場合に使えるのがこちらの方法です。私はこれでやることが多いです。
簡単に言うと、計算の主導権をサイドバー側にしてしまう方法です。

こちらも先にコードを貼ります。まずはGAS側。

gas2.gs
// 実際にする計算
function calc(value) {
  Utilities.sleep(2000);  // 何か計算してる代わり
  value += 1;
  return value;
}

// 状態の初期化
function init() {
}

// スタート
function start() {
  // 初期化
  init();

  // サイドバー表示
  const html = HtmlService.createHtmlOutputFromFile('サイドバー2');
  SpreadsheetApp.getUi().showSidebar(html);
}

随分スッキリしました。ここではスプレッドシートを直接読み書きしていません。
(実際にはcalc()内で色々操作するんでしょうが)

次はサイドバーのHTMLです。

サイドバー2.html
<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <script>
      function iterate(value) {
        google.script.run.withSuccessHandler(function(response) {
          if (response >= 10) {
            document.getElementById("progress").innerHTML = '終了';
          } else {
            document.getElementById("progress").innerHTML = response.toString();
            iterate(response);
          }
        }).calc(value);
      }
      function run() {
        iterate(0);
      }
    </script>
  </head>
  <body onload="run()">
    現在: <span id="progress"></span>
  </body>
</html>

考え方

この方法の基本的な考え方は、以下のようになります。

  1. サイドバーからcalc()をキック
  2. calc()では1区切りごとの計算だけやって結果を返す
  3. サイドバーはcalc()の結果を受け取って表示、続きがあるならcalc()をまたキック
  4. 2〜3を繰り返す

で、これを何も考えずにwhile文で書くとこんな感じでしょう。

function run() {
  while (現在の状態が終了条件を満たさない) {
    var r = calc();
    表示(r);
    var 現在の状態 = なんか計算(r);
  }
}

これを、敢えて再帰で書き直します。

function iterate(前の状態) {
  if (終了条件を満たさない) {
    var r = calc(前の状態);
    var 現在の状態 = なんか計算(r);
    iterate(現在の計態);
  }
}
function run() {
  iterate(初期状態;
}

で、これをサイドバー内のスクリプトで実現します。
しかしcalc()はGAS側なので、google.script.runを使って呼び出します。

function iterate(前の状態) {
  if (終了条件を満たさない) {
    var r = google.script.run.calc(前の状態);
    var 現在の状態 = なんか計算(r);
    iterate(現在の計態);
  }
}
function run() {
  iterate(初期状態;
}

しかしこれは動きません。
上の2の方法のところで説明したとおり、google.script.runでGAS側関数を呼び出したときの返り値はwithSuccessHandler()を使ってコールバックで受け取らないといけません。
つまり、


    var r = google.script.run.calc(前の状態);
    var 現在の状態 = なんか計算(r);
    iterate(現在の計態);

この返り値__rを受け取って以降の処理はコールバック関数として別にする__必要があります。
すると、こうなります。

function iterate(前の状態) {
  // コールバック関数
  var success = function(r) {
    var 現在の状態 = なんか計算(r);
    iterate(現在の計態);
  }
  if (終了条件を満たさない) {
    google.script.run.withSuccessHandler(success).calc(前の状態);
  }
}
function run() {
  iterate(初期状態;
}

最初に示したコードと多少形が違いますが、理屈は同じです。

さらなる工夫

途中でユーザーの確認を入れてもいい場合

で、3の方法も穴がないかと言うとそんなことはないです。
自分が色々やってみると、全体の計算がやたら重くて時間がかかる場合に意味不明のエラーで止まることがあります。
これは原因を突き止められていなくてあくまで想像なのですが…G Suiteではなく無料でGoogleスプレッドシートを使っている場合、GASで一度に6分以上計算をするとタイムアウトします。それと同様の原因で、計算リソースを食いすぎると止められてしまうのかもしれません。

で、そういう場合の対処として、計算途中でユーザとのインタラクションを入れていい場合はこんな工夫ができます。
コールバック関数でiterate()を呼ぶ代わりに、計算を継続するか確認するボタンを表示するshowButton()を呼び出します。

  var success = function(r) {
    var 現在の状態 = なんか計算(r);
    // iterate(現在の計態);     <- 以前のもの
    showButton(現在の計算);  // <- こうする
  }

showButton()の中では、

<button data-status=現在の状態 onClick="iterate(this.dataset.status)">続きを計算する</button>

みたいなボタンを生成します。

これで、calc()を呼び出す前にユーザに続きを計算するか確認できるようになります。
経験上、これを入れると意味不明なエラーで落ちることはほぼないです。
また、元々全部の計算をする必要がなくて、必要な結果が得られたら止めたい(検索とか)場合にもこの方法は有効です。

withFailureHandler()は積極的に使ったほうがいい

上のコードでは一切使っていませんが、google.script.runでGAS側の関数がエラーで落ちた場合のエラーハンドラを登録する withFailureHandler() があります。
単純な異常処理のためだけでなく、これをちゃんと使ったほうがデバッグの生産性も格段に上がるので、実際にここにあるようなコードを書くときは積極的に使った方がいいです。

4
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?