2024年3月20日 星期三

Python-自製螢幕截圖程式

要實現螢幕截圖有許多方式,以 Windows 為例,可以使用 Win32 API 的 StretchBlt 搭配 CreateCompatibleBitmap 來實作;若為了效能考量,也可藉由 DirectX 來處理。但使用這些方法有一個最大的問題,就是難以跨平台。因此,本喵基於「懶惰是程式設計師的美德」,直接使用眾多大神所製作的第三方套件來實現此功能。

首先來看看螢幕截圖程式的演示:

咱們要實現的功能很單純,整體流程如下:

  1. 按下鍵盤上的 PrintScreen 鍵來取得整個螢幕畫面
  2. 框選欲裁切的範圍
  3. 確認目標後顯示框選的螢幕區域

監聽鍵盤訊息

啟動截圖的第一步是確認 PrintScreen 鍵是否已被按下。只是這裡有個難點,就是當鍵盤被按下時,若此時的鍵盤輸入焦點不在咱們的程式上,在沒有特殊處理下,咱們的程式是無法得知此訊息的。為了監聽在任意視窗下的鍵盤訊息,咱們可以使用第三方套件 pynput,它的簡易用法如下:

from pynput import keyboard

def on_key_down(key):
    print(f"key down: {key}")

def on_key_up(key):
    print(f"key upa: {key}")

    if key == keyboard.Key.esc:
        return False

with keyboard.Listener(on_press=on_key_down, on_release=on_key_up) as listener:
    listener.join()

有時後,程式可能需要在中途停止監聽鍵盤。比如按下 PrintScreen 後,需要使用者框選裁切區域,這時咱們可能不希望使用者再次按下 PrintScreen 後,又進行擷取螢幕的動作。為了能停止監聽鍵盤,咱們可以如下使用 pynput:

from threading import Thread
from tkinter import *

from pynput import keyboard

def on_key_up(key):
    print(f"key up: {key}")

    if key == keyboard.Key.esc:
        # 若不使用 thread,
        # 而是直接呼叫 _root_win.destroy(),
        # 將無法執行到 return False,
        # 這會導致之後的 listener.join() 無法結束
        Thread(target=_root_win.destroy).start()
        return False

_root_win = Tk()

listener = keyboard.Listener(on_release=on_key_up)
listener.start()

# 某些會導致程式停在此處的指令,比如以下的 mainloop()
_root_win.mainloop()

print(f"listener is running: {listener.running}")
listener.stop()
print(f"listener is running: {listener.running}")
listener.join()

要使用何種型式,端視程式架構而異。

另外,如 on_key_up() 裡的注解所示,若 listener 的 on_press 和 on_release 所指定的函式都無法回傳 False,當主執行緒執行到 listener.join() 時,listener.join() 將無法結束。各位看官可以更改 on_key_up() 的流程,並以按鍵 Esc 或以關閉視窗的方式來結束程式,並觀察 listener.running 的數值變化。

有一點需要注意,當使用了 listener.stop() 後,若要再次啟動此 listener,無法藉由直接呼叫 listener.start() 來重新啟用它,而是需要創建一個新的 keyboard.Listener 物件,如第 19 和 20 行。

擷取螢幕畫面

要擷取目前螢幕畫面,可以使用 Pillow,它的使用方式非常簡單:

from PIL import ImageGrab

screenshot = ImageGrab.grab()

screenshot 就是目前的螢幕畫面,其類型是 PIL.Image.Image。不過因為要將其顯示在 Tkinter 的視窗內,所以須要將其轉換成 Tkinter 所能接受的影像物件 PIL.ImageTk.PhotoImage,其流程如下:

from tkinter import *

from PIL import ImageGrab, ImageTk

# 創建主視窗
_root_win = Tk()

# 取得螢幕大小
cx_screen = _root_win.winfo_screenwidth()
cy_screen = _root_win.winfo_screenheight()

# 創建畫布以繪製取得的螢幕內容
# highlightthickness 設為 0,
# 否則 Canvas 預設會在周圍加 2 pixel 的邊框
_canvas = Canvas(_root_win, width=cx_screen, height=cy_screen, highlightthickness=0)
_canvas.pack()

# 取得目前螢幕畫面,
# 並轉換成 Tkinter 可識別的物件
screenshot = ImageGrab.grab()
img_tk = ImageTk.PhotoImage(screenshot)

# 將截圖繪製到螢幕上
# 注意:
# create_image() 雖然會以 image 接收 img_tk,
# 但實際上並不保留 img_tk 參考
_canvas.create_image(0, 0, anchor=NW, image=img_tk)

# 監聽 Esc 按鍵的訊息,當按下 Esc 後,會結束主視窗
_root_win.bind("<Escape>", lambda _: _root_win.destroy())

# 將主視窗轉換成全螢幕
_root_win.attributes("-fullscreen", 1)

_root_win.mainloop()

這裡必須特別注意第 27 行的 _canvas.create_image()。如注釋所示,create_image() 並沒有保留 img_tk 的參考。現在因 img_tk 是全域變數,所以 _canvas 可以正常顯示圖像。但若 img_tk 是區域變數,在離開作用域後將會被資源回收處理器清除掉,這將導致 _canvas 無法顯示圖像。因此,必須確保 img_tk 所參考的物件的有效時間至少與 _canvas 的生存時間一樣。

結合鍵盤監控和螢幕擷取

至此,咱們已經知道如何監聽全域的鍵盤事件,以及擷取螢幕畫面的方法。當結合兩者後,便能在任意視窗下,取得感興趣的螢幕副本:

# Standard modules
from threading import Thread
from tkinter import *

# 3rd party modules
from PIL import Image, ImageGrab, ImageTk
from pynput import keyboard


class MainApp:
    def __init__(self):
        self.root_win = Tk()

        cx_screen = self.root_win.winfo_screenwidth()
        cy_screen = self.root_win.winfo_screenheight()

        self.canvas = Canvas(
            self.root_win, width=cx_screen, height=cy_screen, highlightthickness=0
        )
        self.canvas.pack()

        self.root_win.bind("<Escape>", self.on_destroy)

        # 隱藏主視窗
        self.root_win.withdraw()

    def destroy(self):
        self.root_win.destroy()

    def fullscreen(self):
        self.root_win.deiconify()
        self.root_win.attributes("-fullscreen", 1)

    def mainloop(self):
        self.root_win.mainloop()

    def screenshot(self):
        self.img_screenshot = ImageGrab.grab()
        self.draw_screenshot()
        self.fullscreen()

    def draw_screenshot(self):
        img = self.build_screenshot()
        self.canvas.delete(ALL)
        self.canvas.create_image(0, 0, anchor=NW, image=img)
        self.canvas.img_tk = img

    def build_screenshot(self):
        shadow = Image.new("RGB", self.img_screenshot.size, (0xF0, 0xF8, 0xFF))
        alpha = 0.75
        blend = Image.blend(self.img_screenshot, shadow, alpha)

        img = ImageTk.PhotoImage(blend)
        return img

    def on_destroy(self, event):
        self.destroy()


def on_key_up(key):
    if key == keyboard.Key.print_screen:
        _listener.stop()
        _app.screenshot()
        return False

    elif key == keyboard.Key.esc:
        Thread(target=_app.destroy).start()
        return False


if __name__ == "__main__":
    _app = MainApp()

    _listener = keyboard.Listener(on_release=on_key_up)
    _listener.start()

    _app.mainloop()

    _listener.stop()
    _listener.join()

為了方便管理,本喵將主視窗封裝成 MainApp 類別,讓其負責擷取螢幕圖像與顯示。基本上整體與前面的程式沒有太大不同,最大差別在第 48 行的 build_screenshot(),它負責渲染擷取的螢幕。首先,在螢幕副本上覆蓋一個半透明圖層 shadow,以此區分真正螢幕和副本。然後將其轉換成 Tkinter 所能識別的影像物件,讓第 42 行的 draw_screenshot() 可以將其繪出。

另外,還記得先前曾說過,create_image() 並不會保存傳進去的影像參考嗎?為了保存參考,本喵在第 46 行,將其保存在 canvas 內。各位看官可以試著將其移除,以觀察程式的變化。

框選範圍

要選取範圍可以如下進行:

  1. 按下滑鼠左鍵以啟動目標選取作業
  2. 在滑鼠左鍵已按下的狀態下,拖曳滑鼠來框選範圍
  3. 放開滑鼠左鍵以完成選取動作

當然也可以以其他方式進行,這全憑程式設計者的想像力。

在 Tkinter,要監聽滑鼠事件,需要使用到視窗物件的 bind() 函式,以下是會用到的事件:

事件 描述
<Button-1> 當滑鼠左鍵被按下時觸發
<ButtonRelease-1> 當滑鼠左鍵被放開時觸發
<B1-Motion> 當滑鼠左鍵被按下,並移動滑鼠時觸發(即按下左鍵拖曳)

咱們先來寫個簡易的框選程式:

from tkinter import *

def normalize_roi(roi: list[int]) -> list[int]:
    """
    Tkinter 的坐標系是由左往右,由上往下遞增,
    而 roi 所儲存的資訊是 (左, 上, 右, 下),
    但可能 左 > 右 或 上 > 下,
    所以需要正規化
    """

    result = roi.copy()

    if result[0] > result[2]:
        result[0], result[2] = result[2], result[0]

    if result[1] > result[3]:
        result[1], result[3] = result[3], result[1]

    return result


def draw_roi(roi: list[int]):
    roi = normalize_roi(roi)

    _canvas.delete(ALL)
    _canvas.create_rectangle(roi, outline="red")


def on_left_mouse_down(event):
    _roi[0] = _roi[2] = event.x
    _roi[1] = _roi[3] = event.y


def on_left_mouse_drag(event):
    _roi[2] = event.x
    _roi[3] = event.y

    draw_roi(_roi)


# 表示框選座標 (左, 上, 右, 下)
_roi = [0] * 4

_root_win = Tk()

cx_win = 800
cy_win = 600

_canvas = Canvas(_root_win, width=cx_win, height=cy_win, bg="#177245")
_canvas.pack()

# 繫結主視窗的滑鼠事件
_root_win.bind("<Button-1>", on_left_mouse_down)
_root_win.bind("<B1-Motion>", on_left_mouse_drag)

_root_win.mainloop()

各位看官應該會發現,當要擴大選取範圍時,選取框擴大方向的邊有時會消失不見。要改善此狀況,可以在繪製選取框前,先將整個繪製區域填滿背景色,如以下程式的第 28 和 29 行:

from tkinter import *

def normalize_roi(roi: list[int]) -> list[int]:
    """
    Tkinter 的坐標系是由左往右,由上往下遞增,
    而 roi 所儲存的資訊是 (左, 上, 右, 下),
    但可能 左 > 右 或 上 > 下,
    所以需要正規化
    """

    result = roi.copy()

    if result[0] > result[2]:
        result[0], result[2] = result[2], result[0]

    if result[1] > result[3]:
        result[1], result[3] = result[3], result[1]

    return result


def draw_roi(roi: list[int]):
    roi = normalize_roi(roi)

    _canvas.delete(ALL)

    # 填滿背景色,以防止選取框擴大方向的邊消失
    bg = _canvas.config("bg")[-1]
    _canvas.create_rectangle((0, 0, _cx_win, _cy_win), fill=bg, outline=bg)

    _canvas.create_rectangle(roi, outline="red")


def on_left_mouse_down(event):
    _roi[0] = _roi[2] = event.x
    _roi[1] = _roi[3] = event.y


def on_left_mouse_drag(event):
    _roi[2] = event.x
    _roi[3] = event.y

    draw_roi(_roi)


# 表示框選座標 (左, 上, 右, 下)
_roi = [0] * 4

_root_win = Tk()

_cx_win = 800
_cy_win = 600

_canvas = Canvas(_root_win, width=_cx_win, height=_cy_win, bg="#177245")
_canvas.pack()

# 繫結主視窗的滑鼠事件
_root_win.bind("<Button-1>", on_left_mouse_down)
_root_win.bind("<B1-Motion>", on_left_mouse_drag)

_root_win.mainloop()

目前並不清楚為何有這現象。本喵一開始以為是缺乏雙緩衝所導致,但觀察其現象,並不像是如此。而 _canvas.delete(ALL) 應該也已經以背景色將先前的框選範圍清除,所以應該不須自行將背景重新繪製才是。若有看官瞭解其原因,還煩請告知。

鍵盤監控 + 螢幕擷取 + 範圍選取

接著咱們結合先前的螢幕擷取程式:

# Standard modules
from threading import Thread
from tkinter import *

# 3rd party modules
from PIL import Image, ImageDraw, ImageGrab, ImageTk
from pynput import keyboard


class MainApp:
    def __init__(self):
        # 表示框選座標 (左, 上, 右, 下)
        self.roi = [0] * 4

        self.root_win = Tk()

        cx_screen = self.root_win.winfo_screenwidth()
        cy_screen = self.root_win.winfo_screenheight()

        self.canvas = Canvas(
            self.root_win, width=cx_screen, height=cy_screen, highlightthickness=0
        )
        self.canvas.pack()

        self.root_win.bind("<Escape>", self.on_destroy)
        self.root_win.bind("<Button-1>", self.on_left_mouse_down)
        self.root_win.bind("<B1-Motion>", self.on_left_mouse_drag)

        self.root_win.withdraw()

    def destroy(self):
        self.root_win.destroy()

    def fullscreen(self):
        self.root_win.deiconify()
        self.root_win.attributes("-fullscreen", 1)

    def mainloop(self):
        self.root_win.mainloop()

    def screenshot(self):
        self.img_screenshot = ImageGrab.grab()
        self.draw_screenshot()
        self.fullscreen()

    def draw_screenshot(self, roi: list[int] = None):
        img = self.build_screenshot(roi)
        self.canvas.delete(ALL)
        self.canvas.create_image(0, 0, anchor=NW, image=img)
        self.canvas.img_tk = img

    def build_screenshot(self, roi: list[int] = None):
        shadow = Image.new("RGB", self.img_screenshot.size, (0xF0, 0xF8, 0xFF))
        alpha = 0.75
        blend = Image.blend(self.img_screenshot, shadow, alpha)

        if roi and roi[0] != roi[2] and roi[1] != roi[3]:
            roi = self.normalize_roi(roi)

            # 建立遮罩,讓後續複製操作僅作用於所選範圍
            mask = Image.new("1", self.img_screenshot.size)
            draw = ImageDraw.Draw(mask)
            draw.rectangle(roi, fill=1)

            # 將選取的螢幕畫面貼到被半透明圖層覆蓋的螢幕上
            blend.paste(self.img_screenshot, None, mask)

            # 繪製一個紅色的選取框
            draw = ImageDraw.Draw(blend)
            draw.rectangle(roi, outline="red")

        img = ImageTk.PhotoImage(blend)
        return img

    def draw_roi(self):
        self.draw_screenshot(self.roi)

    def normalize_roi(self, roi: list[int]) -> list[int]:
        result = roi.copy()

        if result[0] > result[2]:
            result[0], result[2] = result[2], result[0]

        if result[1] > result[3]:
            result[1], result[3] = result[3], result[1]

        return result

    def on_destroy(self, event):
        self.destroy()

    def on_left_mouse_down(self, event):
        self.roi[0] = self.roi[2] = event.x
        self.roi[1] = self.roi[3] = event.y

    def on_left_mouse_drag(self, event):
        self.roi[2] = event.x
        self.roi[3] = event.y

        self.draw_roi()


def on_key_up(key):
    if key == keyboard.Key.print_screen:
        _listener.stop()
        _app.screenshot()
        return False

    elif key == keyboard.Key.esc:
        Thread(target=_app.destroy).start()
        return False


if __name__ == "__main__":
    _app = MainApp()

    _listener = keyboard.Listener(on_release=on_key_up)
    _listener.start()

    _app.mainloop()

    _listener.stop()
    _listener.join()

本喵在第 52 行的 build_screenshot() 統一處理選取框的繪製。因為先前已經將整個螢幕副本覆蓋了一層半透明的圖層,以 blend 變數指代,所以現在只要將被框選的螢幕繪製到 blend 上就好。不過因為 Pillow.Image.Image.paste() 必須藉由遮罩來進行部分複製的操作,所以在第 61 ~ 63 行本喵製作了一個與螢幕同大小的遮罩,並將選取區域的值設為 1(1 表示會處理,0 則不處理),然後在第 66 行將圖貼到 blend 上。

另外,為了在淺色畫面也能看清選取範圍,於是本喵在第 70 行繪製了一個紅色的選取框。

確認選取範圍與顯示

確認選取範圍很簡單,只要偵測到滑鼠左鍵被放開,便顯示一個對話方塊詢問使用者,若同意,便顯示此截圖;否則讓使用者重新選取區域:

# Standard modules
from threading import Thread
from tkinter import *
from tkinter import messagebox

# 3rd party modules
from PIL import Image, ImageDraw, ImageGrab, ImageTk
from pynput import keyboard


class MainApp:
    def __init__(self):
        # 表示框選座標 (左, 上, 右, 下)
        self.roi = [0] * 4

        self.img_roi_number = 0

        self.root_win = Tk()

        cx_screen = self.root_win.winfo_screenwidth()
        cy_screen = self.root_win.winfo_screenheight()

        self.canvas = Canvas(
            self.root_win, width=cx_screen, height=cy_screen, highlightthickness=0
        )
        self.canvas.pack()

        self.root_win.bind("<Escape>", self.on_destroy)
        self.root_win.bind("<Button-1>", self.on_left_mouse_down)
        self.root_win.bind("<B1-Motion>", self.on_left_mouse_drag)
        self.root_win.bind("<ButtonRelease-1>", self.on_left_mouse_up)

        self.root_win.withdraw()

    def destroy(self):
        self.root_win.destroy()

    def fullscreen(self):
        self.root_win.deiconify()
        self.root_win.attributes("-fullscreen", 1)

    def mainloop(self):
        self.root_win.mainloop()

    def screenshot(self):
        self.img_screenshot = ImageGrab.grab()
        self.draw_screenshot()
        self.fullscreen()

    def draw_screenshot(self, roi: list[int] = None):
        img = self.build_screenshot(roi)
        self.canvas.delete(ALL)
        self.canvas.create_image(0, 0, anchor=NW, image=img)
        self.canvas.img_tk = img

    def build_screenshot(self, roi: list[int] = None):
        shadow = Image.new("RGB", self.img_screenshot.size, (0xF0, 0xF8, 0xFF))
        alpha = 0.75
        blend = Image.blend(self.img_screenshot, shadow, alpha)

        if roi and roi[0] != roi[2] and roi[1] != roi[3]:
            roi = self.normalize_roi(roi)

            # 建立遮罩,讓後續複製操作僅作用於所選範圍
            mask = Image.new("1", self.img_screenshot.size)
            draw = ImageDraw.Draw(mask)
            draw.rectangle(roi, fill=1)

            # 將選取的螢幕畫面貼到被半透明圖層覆蓋的螢幕上
            blend.paste(self.img_screenshot, None, mask)

            # 繪製一個紅色的選取框
            draw = ImageDraw.Draw(blend)
            draw.rectangle(roi, outline="red")

        img = ImageTk.PhotoImage(blend)
        return img

    def draw_roi(self):
        self.draw_screenshot(self.roi)

    def normalize_roi(self, roi: list[int]) -> list[int]:
        result = roi.copy()

        if result[0] > result[2]:
            result[0], result[2] = result[2], result[0]

        if result[1] > result[3]:
            result[1], result[3] = result[3], result[1]

        return result

    def show_roi(self):
        self.img_roi_number += 1
        title = f"Capture {self.img_roi_number}"

        roi = self.normalize_roi(self.roi)
        img = self.img_screenshot.crop((roi[0], roi[1], roi[2] + 1, roi[3] + 1))

        roi_win = RoiWin(img, title)

    def on_destroy(self, event):
        self.destroy()

    def on_left_mouse_down(self, event):
        self.roi[0] = self.roi[2] = event.x
        self.roi[1] = self.roi[3] = event.y

    def on_left_mouse_drag(self, event):
        self.roi[2] = event.x
        self.roi[3] = event.y

        self.draw_roi()

    def on_left_mouse_up(self, event):
        if self.roi[0] != self.roi[2] and self.roi[1] != self.roi[3]:
            if messagebox.askokcancel("選取區域", "接受目前的選取區域嗎?"):
                self.show_roi()
                self.root_win.withdraw()
                start_keyboard_listener()
                return

        self.roi[0] = self.roi[1] = self.roi[2] = self.roi[3] = 0
        self.draw_screenshot()


class RoiWin(Toplevel):
    def __init__(self, img: Image.Image, title: str = None) -> None:
        super().__init__()

        self.img = img

        if title:
            self.title(title)

        self.canvas = Canvas(self, width=img.width, height=img.height)
        self.canvas.pack(anchor=NW)

        self.on_paint()

        self.attributes("-topmost", True)
        self.after_idle(self.attributes, "-topmost", False)

    def on_paint(self):
        self.canvas.delete(ALL)

        img_tk = ImageTk.PhotoImage(self.img)
        self.canvas.create_image(0, 0, anchor=NW, image=img_tk)
        self.canvas.img_tk = img_tk


def on_key_up(key):
    if key == keyboard.Key.print_screen:
        stop_keyboard_listener()
        _app.screenshot()
        return False

    elif key == keyboard.Key.esc:
        Thread(target=_app.destroy).start()
        return False


def start_keyboard_listener():
    global _listener

    _listener = keyboard.Listener(on_release=on_key_up)
    _listener.start()


def stop_keyboard_listener(wait_need=False):
    _listener.stop()

    if wait_need:
        _listener.join()


if __name__ == "__main__":
    _app = MainApp()

    start_keyboard_listener()
    _app.mainloop()
    stop_keyboard_listener(True)

在第 115 行增加 on_left_mouse_up(),以執行滑鼠左鍵被放開時的命令。然後新增 RoiWin 類別,來單獨顯示所有被框選的區域。最後為了更好管控鍵盤的監控開關,在 163 與 170 增加 start_keyboard_listener() 和 stop_keyboard_listener(),以免全域變數流竄各處。至此,基本功能的實現講述完畢。

2019年1月8日 星期二

22-來山寨Monkeyrunner吧 (實作一個弧形拖曳)

各位看官在讀完 sendevent 的介紹後有什麼感想呢?是不是覺得使用 sendevent 有點繁瑣?若用在模擬按鍵的操作上似乎還勉強能夠接受,但一旦要模擬觸控螢幕的操作,比如拖曳,則似乎有些累人。雖然這問題可藉由良好的封裝來解決,但本喵今天就來為各位看官出個蠢主意——山寨一個 Monkeyrunner 吧!!

2018年12月30日 星期日

21-sendevent

上回咱們說到怎麼利用 getevent 得到 Android 的輸入事件,本次就來說明如何使用 sendevent 模擬輸入的動作。

2018年12月23日 星期日

20-getevent

getevent 是一個和模擬輸入無直接相關的指令,但若看官想知道當滑動 Android 觸控面板時是由那些輸入事件所構成,或想記錄點擊、滑動等動作以回放這些操作,那 getevent 將是一種選擇。

2018年12月8日 星期六

19-Level up !!! input draganddrop

拖放 (Drag and Drop) 是種很常見的操作,可是咱們曾介紹的 MonkeyDevice.draginput swipe 都難以漂亮地模擬此動作,並非不能,只是實現的效果非常不理想且醜陋。不過從 Android 8.0.0 開始,有一新的 input 成員能夠較為優雅地實現此模擬了,它便是 input draganddrop

2018年12月2日 星期日

18-input三兄弟swipe

input swipe 的功能和 MonkeyDevice.drag 類似,都是模擬手指滑動螢幕的行為,其使用方式也雷同:
語法 input swipe <x1> <y1> <x2> <y2> [ duration(ms) ]
說明 功能與 MonkeyDevice.drag (tuple start, tuple end, float duration, integer steps) 類似。
duration 預設為 300 ms。

2018年11月5日 星期一

17-input三兄弟tap

語法 input tap <x> <y>
說明 與 MonkeyDevice.touch (<x>, <y>, MonkeyDevice.DOWN_AND_UP) 的行為一樣。

16-input三兄弟keyevent

語法 input keyevent [--longpress] <key code number or name>
說明 與 MonkeyDevice.press(<key name>, MonkeyDevice.DOWN_AND_UP) 的行為類似。
Key name,可查閱 KeyEvent 裡以 KEYCODE_ 為開頭的名稱。
Key code number,可查閱 KeyEvent 裡以 KEYCODE_ 為開頭的名稱的 constant value。
--longpress 此參數目前無法達成長壓按鍵的效果。

咱們直接看例子:

2018年11月4日 星期日

15-輕量級的輸入模擬 (input)

相比於 MonkeyDevice 提供的 press、touch 和 drag 三種方法,本喵更常使用 shell 提供的 input 來執行對應的動作。其實若單以行為的可控制性來說,MonkeyDevice 是勝過 shell input 的,比如 shell input tap 只能執行「點一下螢幕」這樣的動作,而 MonkeyDevice.touch() 還可以模擬「長壓螢幕」的行為。但之所以選擇 shell input,是因為 monkeyrunner 太常丟出 exception 了,雖然這可以藉由良好的封裝來重複嘗試執行,但設計上畢竟較繁複,執行測試時也會花費更多時間在不必要的 try...exception 上;另一個理由則是本喵被分配的工作剛好 shell input 便足夠勝任了,所以除非有必要,否則本喵是傾向使用簡單的方式來達成目的。

2018年10月28日 星期日

14-繞遠路雖愚蠢但有用 (screencap)

screencap 是 Android shell 提供來擷取目前螢幕畫面的工具程式,本喵只使用過以下語法:
語法 說明
screencap -p [FILENAME] [FILENAME],截圖儲存在 Android 裝置上的檔案路徑,格式為 png。

什麼時候會需要以 screencap 來取代 MonkeyDevice.takeSnapshot() 呢?本喵遇過的情境如下:

2018年10月27日 星期六

13-最終兵器MonkeyDevice.shell

Shell,可以說是由裝置外部操控裝置的最後也是最強的一種工具,但這並不意味著 monkeyrunner 提供的功能僅僅是 shell 的包裝,甚或雞肋,他們的關係更像是互補。monkeyrunner 不能做到的事由 shell 來完成;而 shell 不能達成的工作,則由 monkeyrunner 來補足,縱使兩者都具備的功能,其表現也不盡相同。因此何時使用 shell 或 monkeyrunner,端視裝置的能力與看官希望達成的效果而定。

2018年10月22日 星期一

12-心中那一瞬的美麗 (MonkeyImage.writeToFile)

咱們已經知道如何載入圖片檔了,那依照想當然耳的對稱性會認為應該存在一個儲存圖片的方法才是,MonkeyImage.writeToFile (string path, string format) 就是用來將 MonkeyImage 表示的畫面儲存成圖像檔的方法。

2018年10月19日 星期五

11-九又四分之三的回憶 (MonkeyRunner.loadImageFromFile)

咱們已經知道如何擷取螢幕的畫面,也了解怎麼切割與比較它,但看官們一定滿肚子疑惑:「我才不在乎螢幕畫面有沒變化呢!我想知道的是目前畫面是不是我想要的阿!」

是的!只靠先前章節介紹的方法根本無法得知目前 app 運行的進展,咱們需要的是一個可以載入預期畫面的方法,好拿它與目前畫面做比較。但非常令人意外的是,Google monkeyrunner 的官方介紹中卻看不到任何與載入圖片相關的說明?!

2018年10月18日 星期四

10-失去你擁有世界又如何 (MonkeyImage.getSubImage)

通常檢查裝置是否在預期的情境時並不會去比較整個螢幕畫面,因為咱們只在意畫面上最關鍵的部分——可以代表預期影格的最有力的特徵,這主要是為避免誤判,另一方面也是為了加速畫面的比較。雖然 MonkeyImage.sameAs() 無法指定比較的範圍,但咱們可以從 MonkeyImage 物件提取出適當的畫面後再以 MonkeyImage.sameAs() 進行比較,這便是 MonkeyImage.getSubImage (tuple rect) 的作用。

2018年10月14日 星期日

2018年10月10日 星期三

08-為你擷取全世界 (MonkeyDevice.takeSnapshot)

咱們已經學會三種操控手持式裝置的方法:press、touch 和 drag,有了這些動作,已經可以設計些簡單的腳本來操控 app,但除非要執行的動作不需任何判斷(比如狂點螢幕上某些固定地方),否則讓腳本可以根據目前資訊來判斷該做什麼事才是較常見的需求。那什麼是目前資訊呢?以使用者的角度來切入,不外乎就是螢幕目前的畫面,當然,螢幕畫面所能表達的訊息有時也可以藉由向裝置發送命令去獲得,但除非 app 開發商有留下此種資訊能讓外界讀取,否則依然只能藉由畫面本身來判斷。

2018年10月8日 星期一

07-迷霧中的MonkeyDevice.drag

對於手機、平板等手持裝置來說,「滑動」這個動作是必不可少的,因此可以模擬滑動效果的 MonkeyDevice.drag (tuple start, tuple end, float duration, integer steps) 是非常重要的功能,但他的參數與實際表現的落差有時卻讓人迷惑。

2018年10月3日 星期三

05-按下那個按鍵!猴子!(MonkeyDevice.press)

終於,咱們可以開始學習對 Android 裝置下達第一道命令了~按下一個實體按鍵: MonkeyDevice.press (string name, integer type)

所謂的實體按鍵就是諸如電源鍵或聲音鍵這種可以實際觸摸到的硬體,而不是由電腦繪製的 icon 類型的按鈕。以前的 Android 手機上還有 Home key 這種按鍵,但現在也常是以 icon 的形式被表現於手機上了。但即使如此,通常還是可以利用 MonkeyDevice.press() 送出一個模擬 Home key 被按下的訊息給沒有 Home key 的手機,讓其表現按下 Home key 時應有的行為。

2018年10月1日 星期一