? 上一篇博客從源碼層面解釋了appium-inspector工具實現原理,這篇博客將介紹如何從0構建一款簡單的類似appium-inspector的工具。如果要實現一款類似appium-inspector的demo工具,大致需要完成如下六個模塊內容
- 啟動 Appium 服務器
- 連接到移動設備或模擬器
- 啟動應用并獲取頁面源代碼
- 解析頁面源代碼
- 展示 UI 元素
- 生成 Locator
啟動appium服務
? 安裝appium,因為要啟動android的模擬器,后續需要連接到appium server上,所以這里還需要安裝driver,這里需要安裝uiautomater2的driver。
npm install -g appium
appium -v
appium//安裝driver
appium driver install uiautomator2
appium driver list//啟動appium服務
appium
? ?成功啟動appium服務后,該服務默認監聽在4723端口上,啟動結果如下圖所示
連接到移動設備或模擬器
? 在編寫代碼連接到移動設備前,需要安裝android以及一些SDK,然后通過Android studio啟動一個android的手機模擬器,這部分內容這里不再詳細展開,啟動模擬器后,再編寫代碼讓client端連接下appium服務端。
? ?下面代碼通過調用webdriverio這個lib中提供remote對象來連接到appium服務器上。另外,下面的代碼中還封裝了ensureClient()方法,連接appium服務后,會有一個session,這個sessionId超時后會過期,所以,這里增加ensureClient()方法來判斷是否需要client端重新連接appium,獲取新的sessionId信息。
import { remote } from 'webdriverio';
import fs from 'fs';
import xml2js from 'xml2js';
import express from 'express';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';// 獲取當前文件的目錄名
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 加載配置文件
const config = JSON.parse(fs.readFileSync('./src/config.json', 'utf-8'));
// 配置連接參數
const opts = {path: '/',port: 4723,capabilities: {'appium:platformName': config.platformName,'appium:platformVersion': config.platformVersion,'appium:deviceName': config.deviceName,'appium:app': config.app,'appium:automationName': config.automationName,'appium:appWaitActivity':config.appActivity},
};const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
let client;const initializeAppiumClient = async () => {try {client = await remote(opts);console.log('Connected to Appium server');} catch (err) {console.error('Failed to connect to Appium server:', err);}
};
//解決session過期的問題
const ensureClient = async () => {if (!client) {await initializeAppiumClient();} else {try {await client.status();} catch (err) {if (err.message.includes('invalid session id')) {console.log('Session expired, reinitializing Appium client');await initializeAppiumClient();} else {throw err;}}}
};
啟動應用并獲取頁面信息
? 當client端連接到appium server后,獲取當前模擬器上應用頁面信息是非常簡單的,這里需要提前在模擬器上安裝一個app,并開啟app。代碼的代碼中將獲取page source信息,獲取screenshot信息,點擊tap信息都封裝成了api接口,并通過express,在9096端口上啟動了一個后端服務。
app.get('/page-source', async (req, res) => {try {await ensureClient();// 獲取頁面源代碼const pageSource = await client.getPageSource();const parser = new xml2js.Parser();const result = await parser.parseStringPromise(pageSource);res.json(result);} catch (err) {console.error('Error occurred:', err);res.status(500).send('Error occurred');}
});app.get('/screenshot', async (req, res) => {try {await ensureClient();// 獲取截圖const screenshot = await client.takeScreenshot();res.send(screenshot);} catch (err) {console.error('Error occurred:', err);res.status(500).send('Error occurred');}
});app.post('/tap', async (req, res) => {try {await ensureClient();const { x, y } = req.body;await client.touchAction({action: 'tap',x,y});res.send({ status: 'success', x, y });} catch (err) {console.error('Error occurred while tapping element:', err);res.status(500).send('Error occurred');}
});app.listen(9096, async() => {await initializeAppiumClient();console.log('Appium Inspector server running at http://localhost:9096');
});process.on('exit', async () => {if (client) {await client.deleteSession();console.log('Appium client session closed');}
});
? 下圖就是上述服務啟動后,調用接口,獲取到的頁面page source信息,這里把xml格式的page source轉換成了json格式存儲。結果如下圖所示:
顯示appUI以及解析獲取element信息
? 下面的代碼是使用react編寫,所以,可以通過react提供的命令,先初始化一個react項目,再編寫下面的代碼。對于在react編寫的應用上顯示mobile app的ui非常簡單,調用上面后端服務封裝的api獲取page source,使用<imag src=screenshot>就可以在web UI上顯示mobile app的UI。
? 另外,除了顯示UI外,當點擊某個頁面元素時,期望能獲取到該元素的相關信息,這樣才能結合元素信息生成locator,這里封裝了findElementAtCoordinates方法來從pageSource中查找match的元素,查找的邏輯是根據坐標信息,也就是pagesource中bounds字段信息進行匹配match的。
import React, {useState, useEffect, useRef} from 'react';
import axios from 'axios';const App = () => {const [pageSource, setPageSource] = useState('');const [screenshot, setScreenshot] = useState('');const [elementInfo, setElementInfo] = useState(null);const [highlightBounds, setHighlightBounds] = useState(null);const imageRef = useRef(null);const ERROR_MARGIN = 5; // 可以調整誤差范圍const getPageSource = async () => {try {const response = await axios.get('http://localhost:9096/page-source');setPageSource(response.data);} catch (err) {console.error('Error fetching page source:', err);}};const getScreenshot = async () => {try {const response = await axios.get('http://localhost:9096/screenshot');setScreenshot(`data:image/png;base64,${response.data}`);} catch (err) {console.error('Error fetching screenshot:', err);}};useEffect( () => {getPageSource();getScreenshot()}, []);const handleImageClick = (event) => {if (imageRef.current && pageSource) {const rect = imageRef.current.getBoundingClientRect();const x = event.clientX - rect.left;const y = event.clientY - rect.top;// 檢索頁面源數據中的元素pageSource.hierarchy.$.bounds="[0,0][1080,2208]";const element = findElementAtCoordinates(pageSource.hierarchy, x, y);if (element) {setElementInfo(element.$);const bounds = parseBounds(element.$.bounds);setHighlightBounds(bounds);} else {setElementInfo(null);setHighlightBounds(null);}}};const parseBounds = (boundsStr) => {const bounds = boundsStr.match(/\d+/g).map(Number);return {left: bounds[0],top: bounds[1],right: bounds[2],bottom: bounds[3],centerX: (bounds[0] + bounds[2]) / 2,centerY: (bounds[1] + bounds[3]) / 2,};};const findElementAtCoordinates = (node, x, y) => {if (!node || !node.$ || !node.$.bounds) {return null;}const bounds = parseBounds(node.$.bounds);const withinBounds = (x, y, bounds) => {return (x >= bounds.left &&x <= bounds.right &&y >= bounds.top &&y <= bounds.bottom);};if (withinBounds(x, y, bounds)) {for (const child of Object.values(node)) {if (Array.isArray(child)) {for (const grandChild of child) {const foundElement = findElementAtCoordinates(grandChild, x, y);if (foundElement) {return foundElement;}}}}return node;}return null;};return (<div>{screenshot && (<div style={{ position: 'relative' }}><imgref={imageRef}src={screenshot}alt="Mobile App Screenshot"onClick={handleImageClick}style={{ cursor: 'pointer', width: '1080px', height: '2208px' }} // 根據 page source 調整大小/>{highlightBounds && (<divstyle={{position: 'absolute',left: highlightBounds.left,top: highlightBounds.top,width: highlightBounds.right - highlightBounds.left,height: highlightBounds.bottom - highlightBounds.top,border: '2px solid red',pointerEvents: 'none',}}/>)}</div>)}{elementInfo && (<div><h3>Element Info</h3><pre>{JSON.stringify(elementInfo, null, 2)}</pre></div>)}</div>);
};export default App;
? 下圖圖一是android模擬器上啟動了一個mobile app頁面。
? ?下圖是啟動react編寫的前端應用,可以看到,在該應用上顯示了模擬器上的mobile app ui,當點擊某個元素時,會顯示被點擊元素的相關信息,說明整個邏輯已經打通。當點擊password這個輸入框元素時,下面顯示了element info,可以看到成功查找到了對應的element。當然,這個工具只是一個顯示核心過程的demo code。例如higlight的紅框,不是以目標元素為中心畫的。
? ?關于生成locator部分,這里并沒有提供code,當獲取到element信息后,還需要獲取該element的parent element,根據locator的一些規則,編寫方法實現,更多的細節可以參考appium-server 源代碼。
? ? 整個工具的demo code 詳見這里,關于如果啟動應用部分,可以看readme信息。? ?