Я всегда хотел себе портативный удалённый рабочий стол на телефоне, чтобы, например, когда кто-то стучится в аську, а я на балкон покурить вышел, можно было не уходя с балкона посмотреть на телефоне, кто там. Ну или, например, переключить трек, принимая ванну. Да, я знаю, что всевозможные VNC-клиенты уже написаны, но я решил сделать такую программу сам.
В первой части статьи я ограничусь только созданием несложного remote desktop приложения, в котором и сервер, и клиент будут работать на обычных настольных компьютерах. Во второй и третьей части я рассмотрю сжатие изображения и программирование собственно телефона.
Конечную функциональность я представляю себе так: на дектопе (сервер) резидентно висит программка, которая, по пришедшему снаружи UDP-пакету начинает передавать фрагменты изображения на обратный адрес. На телефоне (клиент) отображаются присланные фрагменты. Пользователь может сдвинуть оконо отображения или кликнуть внутри него. Информация о сдвигах и кликах передаётся на сервер так же – по UDP.
Заранее прошу извинить меня за то, что я пишу на С#, так, как-будто бы это Javascript – во первых, в статье я хочу обойтись короткими листингами, во вторых, программа на самом деле несложная и разводить сложные структуры данных тут совсем ни к чему, ну и, в третьих, C# уже не является чисто-ООП языком.
Ввиду небольшой сложности программы и я выбрал самый простой известный мне процесс разработки «на коленке» – последовательные простые усложнения.
Начнём с самой простой программы, которая просто покажет нам фрагмент нашего же рабочего стола, в движении:
Тут всё просто: создаём окно, создаём таймер, по событиям от которого будет захватываться изображение и копироваться в окно, запускаем. Убеждаемся, что всё работает.

Теперь добавим перетаскивание десктопа внутри окна. Сразу после создания формы вставим:
Переменная window_topleft – координата верхнего левого угла той области, которая отображается в окно. Исправим CopyFromScreen:
Отлично! Перетаскивается.
Теперь добавим обработку щелчков левой кнопки мыши, так, чтобы щелчок внутри окна транслировался в щелчок на то, что в этом окне отображается. Для того чтобы отличать перетаскавание от щелчка, я буду запоминать координаты, в которой кнопка мыши была нажаты, и, если по отжатию мышь не ушла слишком далеко, буду генерировать щелчок мыши вместо перетаскивания. Вот так:
Щелчок мыши получается из двух последовательных вызовов функции WinAPI mouse_event. Первый вызов – нажатие кнопки (MOUSEEVENTF_LEFTDOWN), второй – отжатие (MOUSEEVENTF_LEFTUP). Вместе c нажатием кнопки мы передаём перемещение (MOUSEEVENTF_MOVE) мышки в нужные координаты, которые указываются абсолютным значением (MOUSEEVENTF_ABSOLUTE). Ноль абсолютных мышиных координат расположен в верхнем левом углу первичного экрана (PrimaryScreen). Точка (65535, 65535) расположена в нижнем правом углу того же экрана. Все остальные экраны, если они есть в системе, прилегают к этому квадрату.
Ну и, конешно, нужно экспортировать себе mouse_event. Это объявление располагается в объявлении класса:
UDP теряет, задерживает и переупорядочивает пакеты. В решаемой задаче переотправка пакета бессмыслена: к тому моменту, как мы определим что пакет потерялся, уже подойдёт время снова обновлять экран. Именно поэтому я выбрал UDP, а не TCP. Бороться с потерями невозможно, но к ним нужно приспособиться: протокол не должен иметь долгоживущего состяния, а потери пакетов не должны быть фатальными или портить картинку надолго.
Передача фрагмента экрана размером с экран смартфона даже с частотой 10 герц это 3*800*480*10 = 11520000 байт в секуду. Это почти 100 мегабит. Без сжатия не обойтись.
Не нужно перепосылать части экрана, которые не изменились –таких может быть довольно много. Но нельзя полностью отказаться от перепосылки неизменившихся частей – у нас ненадёжный канал, и, на самом деле, мы не знаем, что отображается на клиенте.
Размер окна может изменяться. Например из-за поворота телефона из портретного в ландшафтный режим.
Однако, учесть сразу все эти замечания невозможно – от размышлений встанет работа. Поэтому, для начала просто проигнорируем всё, что можно, ради простоты.
А теперь начнём разделять имеющуюся программу на две – сервер и клиент. Пусть они пока остаются в пределах одного процесса, но пусть они работают в разных тредах и не зависят друг от друга по данным.
На этом эта��е уже можно было бы заставить программу посылать датаграммы самой себе, но это было бы слишком большим шагом. Для начала, качестве канала взаимодействия между этими двуми процессами я выбрал ConcurrentQueue – это thread-safe очередь, предназначенная для реализации взаимодействия по схеме Производитель-Потребитель (Producer-Consumer). По прямому каналу сервер будет поставлять фрагменты изображения клиенту, а клиент, по обратному каналу, будет поставлять информацию о сдвигах окна обозрения и о щелчках мыши.
ConcurrentQueue по многим свойствам родственна UDP, и, когда мы отладим взаимодействие через ConcurrentQueue, я надеюсь просто заменить работу с очередью на отправку и приём датаграмм. Для того, чтобы такая замена была проста, нужно, привести программу к тому, чтобы в очередях передавались последовательности байт небольшой длинны.
Но сначала я буду использовать типизированные очереди.
Разделяем
Сначала определим структуры данных, которые будут использоваться для отправки сообщений от сервера к клиенту и обратно:
Итак, сервер передаёт клиенту изображение, с указанием, где оно располагалось на его, сервера, мониторе. Обратно – сдвиги (Shift) и щелчки мыши (Click); условимся везде использовать серверные координаты.
Теперь скопируем имеющуюся функцию Main ещё раз, переименуем обе копии в Server и Client, и напишем новый Main:
Похоже на текстовое изложение простой блок-схемы, правда?
Удалим из Server всё то, что не относится к захвату изображения с экрана и добавим работу с очередьми. Также, я решил пока фиксировать размер окна на размере 400x300 на клиенте и на сервере, чтобы листинг не вырос ещё на пару абзацев.
Из Client уберём всё то, что за него теперь делает Server:
Внешний вид приложения не изменился, поэтому скриншота не будет.
… и сделать себе remote desktop через пайпы, через http, через RS232 и пр. Достаточно просто написать сериализацию, сжатие и транспорт для объектов, которые ходят в очередях.
В следующей части статьи я опишу сжатие, заточенное под последующую передачу по UDP. Особенностью UDP является небольшой размер атомарно передаваемых данных (пакетов), а так же потери и переупорядочивание пакетов.
В первой части статьи я ограничусь только созданием несложного remote desktop приложения, в котором и сервер, и клиент будут работать на обычных настольных компьютерах. Во второй и третьей части я рассмотрю сжатие изображения и программирование собственно телефона.
Конечную функциональность я представляю себе так: на дектопе (сервер) резидентно висит программка, которая, по пришедшему снаружи UDP-пакету начинает передавать фрагменты изображения на обратный адрес. На телефоне (клиент) отображаются присланные фрагменты. Пользователь может сдвинуть оконо отображения или кликнуть внутри него. Информация о сдвигах и кликах передаётся на сервер так же – по UDP.
Заранее прошу извинить меня за то, что я пишу на С#, так, как-будто бы это Javascript – во первых, в статье я хочу обойтись короткими листингами, во вторых, программа на самом деле несложная и разводить сложные структуры данных тут совсем ни к чему, ну и, в третьих, C# уже не является чисто-ООП языком.
Ввиду небольшой сложности программы и я выбрал самый простой известный мне процесс разработки «на коленке» – последовательные простые усложнения.
Начнём с самой простой программы, которая просто покажет нам фрагмент нашего же рабочего стола, в движении:
using System;
using System.Collections.Generic;
using System.Windows.Forms;
using System.Drawing;
namespace rd2
{
class Program
{
static void Main(string[] args)
{
Form f = new Form();
var timer = new System.Windows.Forms.Timer() { Interval = 40 };
timer.Tick += (s, e) =>
{
Graphics g = f.CreateGraphics();
g.CopyFromScreen(0, 0, 0, 0, f.Size);
g.Dispose();
};
timer.Start();
Application.Run(f);
}
}
}
Тут всё просто: создаём окно, создаём таймер, по событиям от которого будет захватываться изображение и копироваться в окно, запускаем. Убеждаемся, что всё работает.

Теперь добавим перетаскивание десктопа внутри окна. Сразу после создания формы вставим:
Point window_topleft = new Point();
Size mouse_prev_loc = new Size();
bool mouse_lbdown = false;
f.MouseDown += (s,e) => { mouse_lbdown = true; };
f.MouseUp += (s, e) => { mouse_lbdown = false; };
f.MouseMove += (s, e) =>
{
if (mouse_lbdown) window_topleft += mouse_prev_loc - (Size)(e.Location);
mouse_prev_loc = (Size)e.Location;
};
Переменная window_topleft – координата верхнего левого угла той области, которая отображается в окно. Исправим CopyFromScreen:
g.CopyFromScreen(window_topleft.X, window_topleft.Y, 0, 0, f.Size);Отлично! Перетаскивается.
Теперь добавим обработку щелчков левой кнопки мыши, так, чтобы щелчок внутри окна транслировался в щелчок на то, что в этом окне отображается. Для того чтобы отличать перетаскавание от щелчка, я буду запоминать координаты, в которой кнопка мыши была нажаты, и, если по отжатию мышь не ушла слишком далеко, буду генерировать щелчок мыши вместо перетаскивания. Вот так:
Point mouse_down_loc = new Point();
f.MouseDown += (s, e) => { mouse_lbdown = true; mouse_down_loc = e.Location; };
f.MouseUp += (s, e) => {
mouse_lbdown = false;
if( Math.Abs(e.Location.X - mouse_down_loc.X) <1
&& Math.Abs(e.Location.Y - mouse_down_loc.Y) <1)
{
int click_to_x = (window_topleft.X + mouse_down_loc.X)
* 65536 / Screen.PrimaryScreen.Bounds.Width;
int click_to_y = (window_topleft.Y + mouse_down_loc.Y)
* 65536 / Screen.PrimaryScreen.Bounds.Height;
mouse_event((uint)(MOUSEEVENTF_LEFTDOWN | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE),
(uint)click_to_x, (uint)click_to_y, 0, 0);
mouse_event((uint)(MOUSEEVENTF_LEFTUP | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE),
(uint)click_to_x, (uint)click_to_y, 0, 0);
}
};
Щелчок мыши получается из двух последовательных вызовов функции WinAPI mouse_event. Первый вызов – нажатие кнопки (MOUSEEVENTF_LEFTDOWN), второй – отжатие (MOUSEEVENTF_LEFTUP). Вместе c нажатием кнопки мы передаём перемещение (MOUSEEVENTF_MOVE) мышки в нужные координаты, которые указываются абсолютным значением (MOUSEEVENTF_ABSOLUTE). Ноль абсолютных мышиных координат расположен в верхнем левом углу первичного экрана (PrimaryScreen). Точка (65535, 65535) расположена в нижнем правом углу того же экрана. Все остальные экраны, если они есть в системе, прилегают к этому квадрату.
Ну и, конешно, нужно экспортировать себе mouse_event. Это объявление располагается в объявлении класса:
[DllImport("user32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.StdCall)]
public static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint cButtons, uint dwExtraInfo);
private const int MOUSEEVENTF_MOVE = 0x01;
private const int MOUSEEVENTF_LEFTDOWN = 0x02;
private const int MOUSEEVENTF_LEFTUP = 0x04;
private const int MOUSEEVENTF_ABSOLUTE = 0x8000;
О чём следует подумать, прежде чем двигаться дальше
UDP теряет, задерживает и переупорядочивает пакеты. В решаемой задаче переотправка пакета бессмыслена: к тому моменту, как мы определим что пакет потерялся, уже подойдёт время снова обновлять экран. Именно поэтому я выбрал UDP, а не TCP. Бороться с потерями невозможно, но к ним нужно приспособиться: протокол не должен иметь долгоживущего состяния, а потери пакетов не должны быть фатальными или портить картинку надолго.
Передача фрагмента экрана размером с экран смартфона даже с частотой 10 герц это 3*800*480*10 = 11520000 байт в секуду. Это почти 100 мегабит. Без сжатия не обойтись.
Не нужно перепосылать части экрана, которые не изменились –таких может быть довольно много. Но нельзя полностью отказаться от перепосылки неизменившихся частей – у нас ненадёжный канал, и, на самом деле, мы не знаем, что отображается на клиенте.
Размер окна может изменяться. Например из-за поворота телефона из портретного в ландшафтный режим.
Однако, учесть сразу все эти замечания невозможно – от размышлений встанет работа. Поэтому, для начала просто проигнорируем всё, что можно, ради простоты.
Разделяем надвое
А теперь начнём разделять имеющуюся программу на две – сервер и клиент. Пусть они пока остаются в пределах одного процесса, но пусть они работают в разных тредах и не зависят друг от друга по данным.
На этом эта��е уже можно было бы заставить программу посылать датаграммы самой себе, но это было бы слишком большим шагом. Для начала, качестве канала взаимодействия между этими двуми процессами я выбрал ConcurrentQueue – это thread-safe очередь, предназначенная для реализации взаимодействия по схеме Производитель-Потребитель (Producer-Consumer). По прямому каналу сервер будет поставлять фрагменты изображения клиенту, а клиент, по обратному каналу, будет поставлять информацию о сдвигах окна обозрения и о щелчках мыши.
ConcurrentQueue по многим свойствам родственна UDP, и, когда мы отладим взаимодействие через ConcurrentQueue, я надеюсь просто заменить работу с очередью на отправку и приём датаграмм. Для того, чтобы такая замена была проста, нужно, привести программу к тому, чтобы в очередях передавались последовательности байт небольшой длинны.
Но сначала я буду использовать типизированные очереди.
Разделяем
Сначала определим структуры данных, которые будут использоваться для отправки сообщений от сервера к клиенту и обратно:
struct ImageChunk
{
public Rectangle place;
public Bitmap img;
};
struct ControlData
{
public enum Action : byte { Shift, Click };
public Action action;
public Point point;
}
Итак, сервер передаёт клиенту изображение, с указанием, где оно располагалось на его, сервера, мониторе. Обратно – сдвиги (Shift) и щелчки мыши (Click); условимся везде использовать серверные координаты.
Теперь скопируем имеющуюся функцию Main ещё раз, переименуем обе копии в Server и Client, и напишем новый Main:
static void Main(string[] args)
{
var img_channel = new BlockingCollection<ImageChunk>(
new ConcurrentQueue<ImageChunk>() );
var control_channel = new BlockingCollection<ControlData>(
new ConcurrentQueue<ControlData>());
Server(control_channel, img_channel);
Client(img_channel, control_channel);
}
Похоже на текстовое изложение простой блок-схемы, правда?
Удалим из Server всё то, что не относится к захвату изображения с экрана и добавим работу с очередьми. Также, я решил пока фиксировать размер окна на размере 400x300 на клиенте и на сервере, чтобы листинг не вырос ещё на пару абзацев.
static void Server(BlockingCollection<ControlData> input,
BlockingCollection<ImageChunk> output)
{
Point window_topleft = new Point();
Size window_size = new Size(400, 300);
var timer = new System.Windows.Forms.Timer() { Interval = 40 };
timer.Tick += (s, e) =>
{
// перед отправкой изображения разгребём входящую очередь
ControlData incoming;
while (input.TryTake(out incoming))
{
switch (incoming.action)
{
case ControlData.Action.Click:
mouse_event((uint)(MOUSEEVENTF_LEFTDOWN | MOUSEEVENTF_ABSOLUTE
| MOUSEEVENTF_MOVE),
(uint)incoming.point.X, (uint)incoming.point.Y, 0, 0);
mouse_event((uint)(MOUSEEVENTF_LEFTUP | MOUSEEVENTF_ABSOLUTE
| MOUSEEVENTF_MOVE),
(uint)incoming.point.X, (uint)incoming.point.Y, 0, 0);
break;
case ControlData.Action.Shift:
window_topleft = incoming.point;
break;
}
}
// захватим с экрана изображение и отправим его
var b = new Bitmap(window_size.Width,window_size.Height);
var g = Graphics.FromImage(b);
g.CopyFromScreen(window_topleft.X, window_topleft.Y, 0, 0, window_size);
g.Dispose();
output.Add(new ImageChunk() { img = b,
place = new Rectangle(window_topleft, window_size) } );
};
timer.Start();
}
Из Client уберём всё то, что за него теперь делает Server:
static void Client(BlockingCollection<ImageChunk> input,
BlockingCollection<ControlData> output)
{
Form f = new Form(){ ClientSize = new Size(400, 300) };
Point window_topleft = new Point();
Size mouse_prev_loc = new Size();
bool mouse_lbdown = false;
Point mouse_down_loc = new Point();
// обработка движений мыши
f.MouseDown += (s, e) => { mouse_lbdown = true; mouse_down_loc = e.Location; };
f.MouseUp += (s, e) =>
{
mouse_lbdown = false;
if (Math.Abs(e.Location.X - mouse_down_loc.X) < 1
&& Math.Abs(e.Location.Y - mouse_down_loc.Y) < 1)
{
int click_to_x = (window_topleft.X + mouse_down_loc.X)
* 65536 / Screen.PrimaryScreen.Bounds.Width;
int click_to_y = (window_topleft.Y + mouse_down_loc.Y)
* 65536 / Screen.PrimaryScreen.Bounds.Height;
output.Add(new ControlData() { action=ControlData.Action.Click,
point=new Point(click_to_x,click_to_y) });
}
};
f.MouseMove += (s, e) =>
{
if (mouse_lbdown)
{
window_topleft += mouse_prev_loc - (Size)(e.Location);
output.Add(new ControlData()
{ action = ControlData.Action.Shift, point = window_topleft }
);
}
mouse_prev_loc = (Size)e.Location;
};
// приём фрагментов изображения
var timer = new System.Windows.Forms.Timer() { Interval = 40 };
timer.Tick += (s, e) =>
{
ImageChunk incoming;
// если очередь пуста - выходим
if( ! input.TryTake(out incoming,5) ) return;
Graphics g = f.CreateGraphics();
g.DrawImageUnscaled(incoming.img, incoming.place.X - window_topleft.X,
incoming.place.Y - window_topleft.Y);
g.Dispose();
incoming.img.Dispose();
};
timer.Start();
Application.Run(f);
}
Внешний вид приложения не изменился, поэтому скриншота не будет.
NB: кстати, тут можно форкнуть..
… и сделать себе remote desktop через пайпы, через http, через RS232 и пр. Достаточно просто написать сериализацию, сжатие и транспорт для объектов, которые ходят в очередях.
В следующей части статьи я опишу сжатие, заточенное под последующую передачу по UDP. Особенностью UDP является небольшой размер атомарно передаваемых данных (пакетов), а так же потери и переупорядочивание пакетов.
