Статьи

iOS SDK: передовые методы рисования от руки

Это руководство будет основано на результатах предыдущего руководства Mobiletuts +, чтобы создать улучшенную версию приложения для рисования, в котором толщина хода пера плавно изменяется со скоростью рисования пользователя и делает полученный эскиз еще более стилистическим и интересным.


Если вы еще этого не сделали, я настоятельно рекомендую вам пройтись по первому учебнику, прежде чем начинать его. Я сделаю проходящие ссылки на концепции и код из первого урока, но я не буду вдаваться в подробности здесь.

В первом уроке мы реализовали алгоритм, который интерполировал точки касания, полученные при рисовании пальцем на экране, позволяя пользователю рисовать на экране. Интерполяция была выполнена с сегментами кривой Безье (предоставленными классом UIKit в UIKit ), с четырьмя последовательными точками касания, содержащими один сегмент Безье. Затем мы выполнили операцию сглаживания в точке соединения, соединяющей два соседних сегмента, чтобы получить общую гладкую кривую от руки.

Также напомним, что для поддержания производительности рисования и отзывчивости пользовательского интерфейса мы (в определенные моменты) визуализируем чертеж, сгенерированный до тех пор, пока эта точка не окажется в растровом изображении. Это освободило нас для сброса нашего UIBezierPath , не позволяя нашему приложению стать вялым и не отвечающим из-за чрезмерных вычислений по бесконечно растущему пути. Мы выполняли этот шаг всякий раз, когда пользователь убирал палец с экрана.

Теперь давайте поговорим о наших целях для этого урока. В принципе, наше требование простое: пользователь рисует пальцем, следит за тем, как быстро движется его палец, и соответственно изменяет ширину пера. Точное соотношение между скоростью и толщиной, которую мы хотим получить, может быть изменено для достижения различных эстетических эффектов.

Отслеживать скорость рисования достаточно просто; приложение выполняет выборку касания пользователя приблизительно 60 раз в секунду (при условии, что в основном потоке нет замедления), поэтому мгновенная скорость касания пользователя будет пропорциональна расстоянию между двумя последовательными выборками касания.

Очевидный подход, который напрашивается сам собой, заключается в изменении свойства UIBezierPath класса UIBezierPath относительно скорости рисования. Тем не менее, эта простая идея имеет несколько проблем и, в конечном счете, недостаточно хороша для удовлетворения наших требований. Следуя духу первого урока, мы сначала реализуем этот подход, чтобы мы могли изучить его недостатки и подумать об итеративном его улучшении или его полной утилизации и попытках чего-то другого. Вот как все-таки происходит разработка реального кода!

Разрабатывая наше приложение, мы обнаружим, что из-за новых и более сложных требований наше приложение выиграет, если мы переместим некоторый код в фоновый режим, в частности, в код растрового изображения. Для этого мы будем использовать Apple GCD (Grand Central Dispatch).

Давайте углубимся и напишем код!


Запустите Xcode и создайте новый проект с шаблоном «Пустое приложение». Назовите это VariableStrokeWidthTut . Сделайте его универсальным проектом и установите флажок «Использовать автоматический подсчет ссылок», оставив другие опции не отмеченными.

Создание нового проекта

В сводке проекта для обоих устройств выберите любой режим в качестве единственной поддерживаемой ориентации интерфейса, не имеет значения, какой именно. Я выбрал Портрет правой стороной вверх в этом уроке. Для приложения для рисования разумно сохранять единую ориентацию.

Для iPhone выбрана одна ориентация
Для iPad выбраны отдельные ориентации

Как обсуждалось ранее, мы начнем с самой простой возможной идеи, изменяя UIBezierPath lineWidth , и посмотрим, что оно нам дает.

Создайте новый файл, назвав его NaiveVarWidthView и сделайте его подклассом UIView .

Замените весь код в NaiveVarWidthView.m следующим:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
#import “NaiveVarWidthView.h”
 
@implementation NaiveVarWidthView
{
    UIBezierPath *path;
    UIImage *incrementalImage;
    CGPoint pts[5];
    uint ctr;
}
 
– (id)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self) {
        [self setMultipleTouchEnabled:NO];
        path = [UIBezierPath bezierPath];
    }
    return self;
}
 
– (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    ctr = 0;
    UITouch *touch = [touches anyObject];
    pts[0] = [touch locationInView:self];
}
 
– (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];
    ctr++;
    pts[ctr] = p;
    if (ctr == 4)
    {
        pts[3] = CGPointMake((pts[2].x + pts[4].x)/2.0, (pts[2].y + pts[4].y)/2.0);
        [path moveToPoint:pts[0]];
        [path addCurveToPoint:pts[3] controlPoint1:pts[1] controlPoint2:pts[2]];
 
        UIGraphicsBeginImageContextWithOptions(self.bounds.size, YES, 0.0);
 
        if (!incrementalImage)
        {
            UIBezierPath *rectpath = [UIBezierPath bezierPathWithRect:self.bounds];
            [[UIColor whiteColor] setFill];
            [rectpath fill];
        }
        [incrementalImage drawAtPoint:CGPointZero];
        [[UIColor blackColor] setStroke];
 
        float speed = 0.0;
 
        for (int i = 0; i < 3; i++)
        {
            float dx = pts[i+1].x – pts[i].x;
            float dy = pts[i+1].y – pts[i].y;
            speed += sqrtf(dx * dx + dy * dy);
        } // …………….. (2)
 
#define FUDGE_FACTOR 100 // emperically determined
        float width = FUDGE_FACTOR/speed;
 
        [path setLineWidth:width];
        [path stroke];
        incrementalImage = UIGraphicsGetImageFromCurrentImageContext();
        UIGraphicsEndImageContext();
        [self setNeedsDisplay];
 
        [path removeAllPoints];
        pts[0] = pts[3];
        pts[1] = pts[4];
        ctr = 1;
 
    }
}
 
– (void)drawRect:(CGRect)rect
{
    [incrementalImage drawInRect:rect];
}
 
– (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
    [self setNeedsDisplay];
}
– (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
{
    [self touchesEnded:touches withEvent:event];
}
 
@end

Этот код имеет всего несколько модификаций из окончательной версии приложения из первого урока. Я буду только обсуждать, что здесь нового. Ссылаясь на пункты в коде:

  • (1) Мы создаем закадровое растровое изображение для визуализации (рисования), как и раньше Однако на этот раз мы выполняем шаг рендеринга вне экрана после каждого обновления чертежа (то есть после каждой выборки из четырех точек касания, что составляет около 60/4 = 25 раз в секунду). Почему? Это потому, что один экземпляр UIBezierPath может иметь только одно значение lineWidth . Поскольку наша цель – изменить ширину линии в соответствии со скоростью рисования, вместо одного длинного пути Безье, к которому мы продолжаем увеличивать точки (как в первом уроке), нам нужно разложить наш путь на наименьшие возможные сегменты, чтобы каждый мог иметь другое значение lineWidth . Очевидно, что поскольку при определении кубического Безье используются четыре пункта, наши сегменты не могут быть короче этого. Поэтому нам нужно было бы выделить новый объект UIBezierPath для каждых четырех полученных точек, пока не произойдет шаг рендеринга за UIBezierPath . Нам пришлось бы продолжать выделять память для новых UIBezierPath потенциально бесконечно, если бы мы только делали рендеринг растрового изображения из-за того, что пользователь убрал палец с экрана. С другой стороны, мы можем выполнить шаг рендеринга за пределами экрана после каждых четырех полученных точек (или около 60/4 = 25 раз в секунду), так что нам нужно сохранить только один экземпляр UIBezierPath содержащий не более четырех точек. и вот что мы сделали здесь. Мы также могли бы пойти на компромисс и периодически, но реже, делать шаг за UIBezierPath , создавая новые UIBezierPath до тех пор, пока этот шаг не произойдет.
  • (2) Мы используем простую эвристику для значения «скорость», вычисляя прямолинейное расстояние между соседними точками как (грубое) приближение для длины кривой Безье.
  • (3) Мы устанавливаем lineWidth равным обратному lineWidth скорости рисования, умноженному на «коэффициент выдумки», определенный экспериментально (так, чтобы линия имела разумную ширину при средней скорости рисования, с которой, как ожидается, будет рисовать обычный пользователь).
  • (4) После рендеринга за UIBezierPath мы можем удалить все точки в нашем экземпляре UIBezierPath и начать все UIBezierPath . Повторюсь, этот шаг происходит после каждых четырех полученных точек касания.

Вставьте следующий код в AppDelegate.m , чтобы настроить контроллер представления и назначить ему представление, которое является экземпляром NaiveVarWidthView .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
#import “AppDelegate.h”
#import “NaiveVarWidthView.h”
 
@implementation AppDelegate
 
– (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
    self.window.backgroundColor = [UIColor whiteColor];
    UIViewController *vc = [[UIViewController alloc] init];
    self.window.rootViewController = vc;
    vc.view = [[NaiveVarWidthView alloc] initWithFrame:self.window.bounds];
    vc.view.frame = self.window.bounds;
    vc.view.backgroundColor = [UIColor whiteColor];
    [self.window makeKeyAndVisible];
    return YES;
}

Создайте приложение и запустите. Набросайте на своем устройстве и внимательно отметьте результат:

Неудовлетворительный результат с наивным алгоритмом

Здесь ширина линии определенно меняется с изменением скорости рисования, но результаты на самом деле не впечатляют. Ширина скачет довольно резко, а не плавно меняется по кривой, как нам бы хотелось. Давайте посмотрим на эти проблемы более подробно:

Как мы уже обсуждали ранее, свойство lineWidth является фиксированным значением для одного экземпляра UIBezierPath и, к сожалению, нельзя изменять его длину. Несмотря на то, что мы используем наименьшую возможную траекторию Безье (только с четырьмя точками), приращение ширины штриха происходит только на стыке двух соседних траекторий, что приводит к «скачкообразному», а не непрерывному изменению ширины.

Вторая проблема, связанная с реализацией, заключается в том, что, хотя Core Graphics использует абстрактную концепцию «точек» для представления таких размеров, как lineWidth , в действительности наш «холст» фактически состоит из дискретных пикселей. В зависимости от того, имеет ли наше устройство дисплей без Retina или Retina, одна единица длины в единицах точек соответствует одному или двум пикселям соответственно. Несмотря на то, что, как и любой хороший API векторного рисования, внутренние алгоритмы, используемые Core Graphics, используют некоторые «уловки» (такие как сглаживание), чтобы визуально отображать нецелую ширину линий, нереально ожидать рисования линий произвольной толщины – например, линия, имеющая ширину, скажем, 2,1 точки, вероятно, будет отображаться идентично линии шириной 2,0 точки. И наоборот, ощутимое изменение рендеринга происходит только при большом увеличении значения свойства lineWidth . Обратите внимание, что проблема дискретизации является вездесущей, но в то же время правильный подход или алгоритм могут иметь все значение.

Возможно, вам удастся улучшить результаты незначительно, lineWidth расчеты lineWidth и т. Д., Но я думаю, что этот подход принципиально ограничен, и поэтому мы должны подойти к этой проблеме с новой точки зрения.

Прежде чем перейти к этому, давайте обратимся к тому факту, что мы теперь периодически делаем шаг закадрового рендеринга (фактически до 25 раз в секунду), и, что еще более важно, мы сейчас делаем это между получением точки касания. На своем iPhone 4 я определил (используя счетчик и таймер, который срабатывал каждую секунду), что это приводило к падению скорости обнаружения касания с 60-63 в секунду (для кода из первого урока) до примерно 48-52 в секунду. второе, что является замечательной каплей! Очевидно, что это приводит к снижению отзывчивости приложения и еще больше ухудшит качество интерполяции, в результате чего результирующая кривая будет выглядеть менее гладкой. Строго говоря, мы должны использовать инструмент Instruments для анализа производительности приложения, но для целей этого урока, скажем, мы сделали это и убедились, что операция рендеринга вне экрана занимает больше всего времени.

Проблема с нашим кодом заключается в touchesMoved:withEvent: метод: после получения каждой четвертой точки касания, элемент управления вводит в тело оператора if, выполняет трудоемкий код рендеринга, и только после его завершения он выходит из тела метод. Пока это не произойдет, пользовательский интерфейс не сможет обработать следующее касание.

Этот тип проблемы, в общих чертах, не является редкостью. У нас есть трудоемкая операция (в данном случае, вне экрана рендеринга), чей результат (растровое изображение) полезен только после завершения всей операции. В то же время, у нас есть несколько коротких, но частых событий, которые не переносят задержку (здесь, сенсорный захват). Если у нас есть несколько процессоров для запуска нашего кода, мы бы хотели разделить два «пути кода», чтобы они могли выполняться независимо, каждый на своем собственном процессоре. Даже если у нас есть один процессор, на котором выполняется наш код, мы бы хотели упорядочить вещи так, чтобы у нас было два отдельных пути кода, причем выполнение последнего перемежается между первым и время планирования процессора для каждого пути кода в соответствии с его время и приоритетные требования. Надеюсь, ясно, что мы только что описали многопоточность (хотя и в очень упрощенном виде).

У нас есть один признак того, что многопоточность будет полезна в этой ситуации: нам необходимо рисовать изображение только один раз для каждых четырех последовательных точек касания, поэтому в действительности, если все устроено правильно, для растрового изображения остается больше времени. рисовать код для запуска, чем мы использовали выше.


В общих чертах, вы хотите удалить код рендеринга из основного потока, который отвечает за рисование на экране и обработку пользовательских событий. IOS SDK предлагает несколько вариантов для достижения этой цели, включая ручное создание потоков, NSOperation и Grand Central Dispatch (GCD). Здесь мы будем использовать GCD. В этом учебнике невозможно подробно рассказать о GCD, поэтому моя идея состоит в том, чтобы объяснить, какие биты мы используем, когда я провожу вас через код. Я чувствую, что если вы понимаете «шаблон проектирования», который мы собираемся применить, и то, как он помогает решить проблему, вы сможете адаптировать ее к другим проблемам аналогичного характера, например, загружая большие объемы Интернет-данные, выполнение сложной операции фильтрации изображения и т. Д. При сохранении отзывчивости пользовательского интерфейса.

Создайте новый подкласс NaiveVarWidthBGRenderingView именем NaiveVarWidthBGRenderingView .

Вставьте следующий код в NaiveVarWidthBGRenderingView.m :

001
002
003
004
005
006
007
008
009
010
011
012
013
014
+015
016
+017
018
019
020
021
022
023
024
025
026
027
028
029
+030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
+055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
#import “NaiveVarWidthBGRenderingView.h”
 
#define CAPACITY 100 // buffer capacity
 
@implementation NaiveVarWidthBGRenderingView
{
 
    UIImage *incrementalImage;
    CGPoint pts[5];
    uint ctr;
    CGPoint pointsBuffer[CAPACITY];
    uint bufIdx;
    dispatch_queue_t drawingQueue;
 
}
 
– (id)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self) {
        [self setMultipleTouchEnabled:NO];
        drawingQueue = dispatch_queue_create(“drawingQueue”, NULL);
    }
    return self;
}
 
– (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    ctr = 0;
    bufIdx = 0;
    UITouch *touch = [touches anyObject];
    pts[0] = [touch locationInView:self];
}
 
– (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];
    ctr++;
    pts[ctr] = p;
    if (ctr == 4)
    {
        pts[3] = CGPointMake((pts[2].x + pts[4].x)/2.0, (pts[2].y + pts[4].y)/2.0);
        pointsBuffer[bufIdx] = pts[0];
        pointsBuffer[bufIdx + 1] = pts[1];
        pointsBuffer[bufIdx + 2] = pts[2];
        pointsBuffer[bufIdx + 3] = pts[3];
 
        bufIdx += 4;
 
        CGRect bounds = self.bounds;
        dispatch_async(drawingQueue, ^{ // …………….. (3)
            if (bufIdx == 0) return;
            UIBezierPath *path = [UIBezierPath bezierPath];
            for ( int i = 0; i < bufIdx; i += 4)
            {
                [path moveToPoint:pointsBuffer[i]];
                [path addCurveToPoint:pointsBuffer[i+3] controlPoint1:pointsBuffer[i+1] controlPoint2:pointsBuffer[i+2]];
            }
 
            UIGraphicsBeginImageContextWithOptions(bounds.size, YES, 0.0);
 
            if (!incrementalImage) // first time;
            {
                UIBezierPath *rectpath = [UIBezierPath bezierPathWithRect:self.bounds];
                [[UIColor whiteColor] setFill];
                [rectpath fill];
            }
            [incrementalImage drawAtPoint:CGPointZero];
            [[UIColor blackColor] setStroke];
 
            float speed = 0.0;
            for (int i = 0; i < 3; i++)
            {
                float dx = pts[i+1].x – pts[i].x;
                float dy = pts[i+1].y – pts[i].y;
                speed += sqrtf(dx * dx + dy * dy);
            }
 
#define FUDGE_FACTOR 100 // emperically determined
            float width = FUDGE_FACTOR/speed;
            [path setLineWidth:width];
            [path stroke];
            incrementalImage = UIGraphicsGetImageFromCurrentImageContext();
            UIGraphicsEndImageContext();
            dispatch_async(dispatch_get_main_queue(), ^{ // …………….. (5)
                bufIdx = 0;
                [self setNeedsDisplay];
            });
        });
        pts[0] = pts[3];
        pts[1] = pts[4];
        ctr = 1;
    }
}
 
– (void)drawRect:(CGRect)rect
{
    [incrementalImage drawInRect:rect];
}
 
– (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
 
    [self setNeedsDisplay];
}
 
– (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
{
    [self touchesEnded:touches withEvent:event];
}
 
@end

Измените AppDelegate.m для #include NaiveVarWidthBGRenderingView и установите представление контроллера корневого view в качестве экземпляра NaiveVarWidthBGRenderingView . Простая замена строки NaiveVarWidthView на NaiveVarWidthBGRenderingView везде в AppDelegate.m сделает свое дело.

Запустите код. Мы еще не коснулись нашего кода для рисования, поэтому нет ничего нового, чтобы увидеть. Надеюсь, вы будете удовлетворены, зная, что ваш код более эффективно использует ресурсы обработки вашего устройства и, вероятно, работает лучше на старых устройствах. На моем iPhone 4 с тем же тестом, описанным выше, скорость получения сенсорного экрана вернулась к своему максимальному значению (60-63 в секунду).

Теперь давайте изучим код со ссылкой на пронумерованные точки в листинге кода:

  • (1) Мы ввели массив для хранения входящих точек pointsBuffer . Я объясню, почему именно немного. Размер буфера (100) был выбран произвольно; на самом деле мы не ожидаем, что этот буфер будет заполнен за пределами четырех точек, принадлежащих одному сегменту кривой Безье. Но это там, чтобы справиться с определенной ситуацией, которая может возникнуть.
  • (2) GCD абстрагирует потоки, стоящие за концепцией очереди. Мы представляем задачи (единицы работы) в очереди. Существует два типа очередей: параллельная и последовательная. Мы будем говорить только о последовательных очередях здесь, потому что это единственный тип, который мы явно используем. Последовательная очередь выполняет задачи, поставленные перед ней, строго по принципу «первым пришел – первым вышел», очень похоже на очередь «первым пришел – первым обслужен» у кассира банка или кассира в супермаркете. Слово «серийный» также означает, что задание будет выполнено до того, как будет запущено следующее, так же, как кассир в супермаркете не начнет обслуживать следующего клиента, пока он не закончит обслуживать текущего клиента. Здесь мы создали очередь и присвоили ей идентификатор drawingQueue . Помните, что весь код, который мы обычно пишем, неявно выполняется в всегда существующей основной очереди, которая сама является последовательной очередью! Итак, теперь у нас есть две очереди. На самом деле мы еще не запланировали никаких работ в очереди на рисование.
  • (3) Вызов функции dispatch_async() планирует на drawingQueue , код рисования растрового изображения, упакованный в блок ^ {…}, асинхронно. «Асинхронный» подразумевает, что хотя задание было отправлено, оно еще не выполнено. Фактически dispatch_async() немедленно возвращает управление вызывающей стороне, в этом случае тело метода (-)touchesMoved:withEvent: (в главной очереди). Это принципиальное отличие от нашей предыдущей (не основанной на потоках) реализации. Все, что раньше происходило в главной очереди, и код рисования растрового изображения должен был быть выполнен до завершения! Убедитесь, что вы поняли это различие. В нашей нынешней реализации вполне возможно, что на многоядерном устройстве очередь рисования будет создана на другом ядре, нежели то, которое обрабатывает основную очередь, и обе очереди обрабатываются одновременно, во многом как небольшой супермаркет с двумя кассирами, который предоставляет услуги две очереди клиентов одновременно. Чтобы понять, как работают устройства с одним процессором, рассмотрим следующую аналогию: представьте офис с одним копировальным аппаратом. «Парень-копировщик» имеет массу работы, которую он получил навалом, и которую он, как ожидается, займет целый день, чтобы завершить. Тем не менее, время от времени один из сотрудников офиса приносит ему несколько страниц для фотокопии. Очевидно, что для него разумно временно прервать трудоемкую работу, на которой он работал в течение дня, и выполнить короткую (но якобы срочную) работу, представленную ему сотрудником, а затем вернуться к своим прежним обязанностям. , В этой аналогии короткая, но срочная потребность сотрудника в фотокопии относится к высокоприоритетным задачам, которые появляются в основной очереди, таким как сенсорные события или рисование на экране, тогда как массовая работа относится к трудоемким задачам, таким как загрузка данных из Интернет или (в нашем случае) рисование в буфер вне экрана. Операционная система ведет себя как умный копировщик, планируя задачи на одном процессоре (одинокий копировальный аппарат) таким образом, чтобы наилучшим образом удовлетворить потребности приложения (офиса). (Надеюсь, аналогия не была слишком глупой!) В любом случае, фактический код, переданный в очередь рисования, в значительной степени соответствует тому, что мы имели в нашей более ранней реализации, за исключением нашего использования буфера, к которому мы добавляем наши точки касания, которые я ‘ обсудим дальше.
  • (4) Этот pointsBuffer кода связан с нашим использованием массива pointsBuffer . Рассмотрим гипотетический сценарий того, что закадровая задача рисования ставится в очередь в очереди на рисование, но по какой-то причине не имеет возможности выполнить ее, и в то же время в основной очереди были получены следующие четыре точки касания и еще одна задача для рисования. ставится в очередь на рисование, за первой. Кто знает, может быть, наше приложение было более сложным и в то же время происходило и другое. Буферизуя наши точки касания, мы можем гарантировать, что в случае закадровых задач рисования с несколькими заданиями первая выполняет всю работу по рисованию, а те, что после нее, просто возвращаются из-за того, что буфер точек пуст. Как я уже говорил ранее, этот сценарий резервного копирования очереди рисования с двумя или более задачами рисования, все из которых ожидают выполнения, может вообще не произойти, и если это происходит на постоянной основе, то это может означать, что наш алгоритм был слишком медленным для устройства, будь то из-за его сложности, плохого дизайна или нашего приложения, пытающегося сделать слишком много вещей. Но на случай, если это случится, мы справились.
  • (5) Все действия по обновлению пользовательского интерфейса должны выполняться в главной очереди, что мы будем делать с другой асинхронной диспетчеризацией из задачи рисования в очереди рисования, как и в предыдущем вызове dispatch_async() , задача обновления экрана имеет отправлено, но это не означает, что приложение собирается отбросить то, что оно делает, и сразу же выполнить его.

Реализованный нами шаблон в целом выглядит следующим образом и применим ко многим другим сценариям:

1
2
3
4
5
6
7
// Main queue
dispatch_async(aSerialQueue, ^{
   // background processing
   dispatch_async(mainQueue, ^{
   // update UI with results
   });
});

В общем, написание многопоточного кода может быть непростой задачей. Но это не всегда так сложно, как вы думаете (как показывает наш собственный пример). Иногда это может показаться «неблагодарным делом», потому что нет явного «вау-фактора», который вы можете показать в конце. Но всегда имейте в виду, что если пользовательский интерфейс вашего приложения работает так же гладко, как масло, то ваши пользователи с большей вероятностью будут наслаждаться его использованием и возвращаться к нему снова и снова!


На первой итерации мы определили, что вряд ли мы добьемся значительных улучшений в получении непрерывного и плавного изменения ширины пера с помощью «наивного» подхода, с которого мы начали. Итак, теперь давайте попробуем новый подход.

Метод, который я собираюсь представить здесь, довольно прост, хотя он требует от нас нестандартного мышления. Вместо того, чтобы представлять наш нарисованный штрих с одной кривой Безье, как мы делали ранее, теперь мы представляем его заполненной областью между двумя путями Безье, каждый путь слегка смещен по обе стороны от воображаемой кривой, отслеживаемой пальцем пользователя. Немного изменив смещения контрольных точек, которые определяют эти две кривые Безье, нам удастся добиться очень правдоподобного эффекта плавно изменяющейся ширины пера.

Offset_Method

На рисунке выше показана конструкция, описанная ранее для одного кубического сегмента Безье. Х с красными кружками вокруг них будут соответствовать захваченным точкам касания, а пунктирная коричневая кривая – это сегмент Безье, созданный из этих точек. Он соответствует пути Безье, который мы нарисовали в наших предыдущих реализациях.

Для каждой из четырех точек касания генерируется пара точек смещения, показанных на любом конце сегмента зеленой линии. Эти сегменты зеленой линии выполнены перпендикулярными сегменту линии, соединяющему две смежные точки касания. Таким образом, мы генерируем два набора из четырех точек по обе стороны от набора точек касания, и каждый из этих наборов точек смещения можно использовать для создания кривой смещения Безье, которая будет лежать по обе стороны от кривой отслеживаемого Безье (две сплошные коричневые кривые ). Из рисунка должно быть ясно, что изменение ширины контролируется расстояниями точек смещения (т. Е. Длиной зеленых отрезков). Если мы заполним область между этими двумя кривыми смещения, мы эффективно смоделировали «ход» переменной ширины!

Этот подход лучше использует то, как векторное рисование работает внутри платформы Core Graphics / UIKit, потому что он лучше моделирует непрерывное изменение, по сравнению с «резким» подходом изменения ширины штриха в «наивном» методе, а в нижней строке он работает хорошо ,

Основным шагом, который нам нужно реализовать, является метод, который может дать нам координаты этих точек смещения. Давайте уточним задачу более точно и геометрически. У нас есть отрезок прямой, соединяющий точки p1 = (x1, y1) и p2 = (x2, y2) , которые я обозначу как p1-p2 . Мы хотели бы найти линию, проходящую через p2 , перпендикулярную p1-p2 . Эту проблему легко решить, если сформулировать ее в терминах векторов. Сегмент линии p1-p2 может быть представлен уравнением p = p1 + (p2 - p1)t , где t – переменный параметр. Изменение t от 0 до 1 заставляет p «сместиться» от p1 до p2 вдоль прямой, соединяющей две точки. Два частных случая: t = 0 соответствует p = p1 , а t = 1 соответствует p = p2 .

Мы можем разделить это параметрическое уравнение по координатам x и y, чтобы получить пару уравнений x = x1 + t(x2 - x1) и y = y1 + t(y2 - y1) , где p = (x, y) , Нам нужно вызвать теорему из геометрии, которая гласит, что произведение уклонов двух перпендикулярных линий равно -1. Наклон линии через (x1, y1) и (x2, y2) равен (y2-y1)/(x2-x1) . Используя это свойство и некоторые алгебраические манипуляции, мы можем определить конечные точки pa и pb линии, перпендикулярной p1-p2 , так что pa и pb находятся на одинаковом расстоянии от p2 . Длина pa-pb может контролироваться переменной, выражающей отношение длины этой линии к p1-p2 . Вместо того, чтобы выписать кучу беспорядочных уравнений, я нарисовал фигуру, которая должна все прояснить.

Уравнения для конечных точек линии PA-PB, которая (1) перпендикулярна p1-p2, (2) разделена пополам в p2, и (3) имеет длину fx длину p1-p2

Давайте реализовывать эти идеи в коде! Создайте FinalAlgView как подкласс UIView и вставьте в него следующий код. Кроме того, не забудьте изменить AppDelegate.m, чтобы использовать этот класс как представление контроллера представления:

001
002
003
004
005
006
007
008
009
010
011
012
013
014
+015
016
+017
018
019
020
021
022
023
024
025
026
027
028
029
+030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
+055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
#define CAPACITY 100
#define FF .2
#define LOWER 0.01
#define UPPER 1.0
 
#import “FinalAlgView.h”
 
typedef struct
{
    CGPoint firstPoint;
    CGPoint secondPoint;
} LineSegment;
 
@implementation FinalAlgView
{
 
    UIImage *incrementalImage;
    CGPoint pts[5];
    uint ctr;
    CGPoint pointsBuffer[CAPACITY];
    uint bufIdx;
    dispatch_queue_t drawingQueue;
    BOOL isFirstTouchPoint;
    LineSegment lastSegmentOfPrev;
 
}
 
– (id)initWithFrame:(CGRect) frame
{
    self = [super initWithFrame:frame];
    if (self) {
 
        [self setMultipleTouchEnabled:NO];
        drawingQueue = dispatch_queue_create(“drawingQueue”, NULL);
        UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(eraseDrawing:)];
        tap.numberOfTapsRequired = 2;
        [self addGestureRecognizer:tap];
 
    }
    return self;
}
 
– (void)eraseDrawing:(UITapGestureRecognizer *)t
{
    incrementalImage = nil;
    [self setNeedsDisplay];
}
– (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    ctr = 0;
    bufIdx = 0;
    UITouch *touch = [touches anyObject];
    pts[0] = [touch locationInView:self];
    isFirstTouchPoint = YES;
}
 
– (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
    UITouch *touch = [touches anyObject];
    CGPoint p = [touch locationInView:self];
    ctr++;
    pts[ctr] = p;
    if (ctr == 4)
    {
        pts[3] = CGPointMake((pts[2].x + pts[4].x)/2.0, (pts[2].y + pts[4].y)/2.0);
 
        for ( int i = 0; i < 4; i++)
        {
            pointsBuffer[bufIdx + i] = pts[i];
        }
 
        bufIdx += 4;
 
        CGRect bounds = self.bounds;
 
        dispatch_async(drawingQueue, ^{
            UIBezierPath *offsetPath = [UIBezierPath bezierPath];
            if (bufIdx == 0) return;
 
            LineSegment ls[4];
            for ( int i = 0; i < bufIdx; i += 4)
            {
                if (isFirstTouchPoint) // …………….. (3)
                {
                    ls[0] = (LineSegment){pointsBuffer[0], pointsBuffer[0]};
                    [offsetPath moveToPoint:ls[0].firstPoint];
                    isFirstTouchPoint = NO;
                }
 
                else
                    ls[0] = lastSegmentOfPrev;
 
                float frac1 = FF/clamp(len_sq(pointsBuffer[i], pointsBuffer[i+1]), LOWER, UPPER);
                float frac2 = FF/clamp(len_sq(pointsBuffer[i+1], pointsBuffer[i+2]), LOWER, UPPER);
                float frac3 = FF/clamp(len_sq(pointsBuffer[i+2], pointsBuffer[i+3]), LOWER, UPPER);
                ls[1] = [self lineSegmentPerpendicularTo:(LineSegment){pointsBuffer[i], pointsBuffer[i+1]} ofRelativeLength:frac1];
                ls[2] = [self lineSegmentPerpendicularTo:(LineSegment){pointsBuffer[i+1], pointsBuffer[i+2]} ofRelativeLength:frac2];
                ls[3] = [self lineSegmentPerpendicularTo:(LineSegment){pointsBuffer[i+2], pointsBuffer[i+3]} ofRelativeLength:frac3];
 
                [offsetPath moveToPoint:ls[0].firstPoint];
                [offsetPath addCurveToPoint:ls[3].firstPoint controlPoint1:ls[1].firstPoint controlPoint2:ls[2].firstPoint];
                [offsetPath addLineToPoint:ls[3].secondPoint];
                [offsetPath addCurveToPoint:ls[0].secondPoint controlPoint1:ls[2].secondPoint controlPoint2:ls[1].secondPoint];
                [offsetPath closePath];
 
                lastSegmentOfPrev = ls[3];
                // Suggestion: Apply smoothing on the shared line segment of the two adjacent offsetPaths
 
            }
            UIGraphicsBeginImageContextWithOptions(bounds.size, YES, 0.0);
 
            if (!incrementalImage)
            {
                UIBezierPath *rectpath = [UIBezierPath bezierPathWithRect:self.bounds];
                [[UIColor whiteColor] setFill];
                [rectpath fill];
            }
            [incrementalImage drawAtPoint:CGPointZero];
            [[UIColor blackColor] setStroke];
            [[UIColor blackColor] setFill];
            [offsetPath stroke];
            [offsetPath fill];
            incrementalImage = UIGraphicsGetImageFromCurrentImageContext();
            UIGraphicsEndImageContext();
            [offsetPath removeAllPoints];
            dispatch_async(dispatch_get_main_queue(), ^{
                bufIdx = 0;
                [self setNeedsDisplay];
            });
        });
        pts[0] = pts[3];
        pts[1] = pts[4];
        ctr = 1;
    }
}
 
– (void)drawRect:(CGRect)rect
{
    [incrementalImage drawInRect:rect];
}
 
– (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
    // Left as an exercise!
 
    [self setNeedsDisplay];
}
 
– (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
{
    [self touchesEnded:touches withEvent:event];
}
 
-(LineSegment) lineSegmentPerpendicularTo: (LineSegment)pp ofRelativeLength:(float)fraction
{
    CGFloat x0 = pp.firstPoint.x, y0 = pp.firstPoint.y, x1 = pp.secondPoint.x, y1 = pp.secondPoint.y;
 
    CGFloat dx, dy;
    dx = x1 – x0;
    dy = y1 – y0;
 
    CGFloat xa, ya, xb, yb;
    xa = x1 + fraction/2 * dy;
    ya = y1 – fraction/2 * dx;
    xb = x1 – fraction/2 * dy;
    yb = y1 + fraction/2 * dx;
 
    return (LineSegment){ (CGPoint){xa, ya}, (CGPoint){xb, yb} };
 
}
 
float len_sq(CGPoint p1, CGPoint p2)
{
    float dx = p2.x – p1.x;
    float dy = p2.y – p1.y;
    return dx * dx + dy * dy;
}
 
float clamp(float value, float lower, float higher)
{
    if (value < lower) return lower;
    if (value > higher) return higher;
    return value;
}
 
@end

Давайте изучим этот код, опять же со ссылкой на пронумерованные комментарии:

  • (1) LineSegment – это простая структура C, которая была typedef ‘d для удобной упаковки двух CGPoints в конце сегмента линии. Ничего особенного.
  • (2) offsetPath – это путь, который мы будем заполнять и offsetPath чтобы достичь нашей переменной толщины пера. Он будет состоять из замкнутого пути (то есть его первая точка будет соединена с последней, чтобы его можно было заполнить), состоящего из двух подпутей Безье, смещенных по обе стороны от трассируемого пути, плюс двух отрезков прямой линии, соединяющих соответствующие концы из двух подпутей.
  • (3) Здесь мы имеем дело с особым случаем первого касания, когда пользователь показывает пальцем на изображение. Мы не будем создавать точки смещения для этой первой точки.
  • (4) Это фактор, используемый для определения скорости рисования (принимая расстояние между двумя точками касания за скорость пользователя). Функция len_sq() возвращает квадрат расстояния между двумя точками. Почему квадрат расстояния? Я объясню это в следующем пункте. Как и прежде, FF – это «фактор выдумки», который я выбрал после проб и ошибок, чтобы получить визуально приятные результаты. Функция clamp() удерживает значение аргумента ниже или ниже установленного порогового значения, чтобы избежать слишком толстого или слишком тонкого хода пера. Опять же, значения LOWER и UPPER были выбраны после некоторых проб и ошибок.
  • (5) Мы создаем метод (-)lineSegmentPerpendicularTo:ofRelativeLength: для реализации геометрической идеи, на которой основан наш подход, как обсуждалось ранее. Первый аргумент соответствует p1-p2 на рисунке. На рисунке видно, что чем длиннее p1-p2 , тем длиннее будет p1-p2 pa-pb (в абсолютном выражении). Таким образом, делая f обратно пропорциональным длине p1-p2 , мы «отменим» эту зависимость от длины, так что, например, f = 0.5/length(p1-p2) сделает pa-pb длиной 1 точка, не зависящая от длины p1-p2 . Чтобы сделать так, чтобы длина pa-pb варьировалась в зависимости от длины p1-p2, я снова разделил на длину p1-p2, . Это мотивация для обратного квадрата фактора длины из предыдущей точки.
  • (6) Это просто создает замкнутый путь путем объединения двух подпутей Безье и двух отрезков прямой. Обратите внимание, что подпути, содержащие offsetPath должны быть добавлены в определенной последовательности, так что каждый подпуть начинается с последней точки предыдущей. Обратите особое внимание на направление второго кубического сегмента Безье. Вы могли бы проследить форму типичного offsetPath , следуя последовательности в коде, чтобы понять, как она формируется.
  • (7) Это просто обеспечивает непрерывность между двумя соседними offsetPath .
  • (8) Мы оба обводим и заполняем путь. Если мы не offsetPath , тогда соседние сегменты offsetPath иногда кажутся несмежными.

Создайте приложение и запустите его. Я думаю, вы согласитесь, что незначительное изменение ширины нарисованной линии при рисовании создает интересный стилистический эффект.

Results_Enhanced

Для сравнения, вот каков был конечный эффект с алгоритмом с фиксированной шириной штриха из исходного урока:

Results_Original

Мы начали с приложения для рисования от руки, усовершенствовали его, добавив многопоточность, и ввели стилистический эффект в алгоритм рисования. Как всегда, есть возможности для улучшения.Конец касания (то есть когда пользователь поднимает палец после рисования) должен обрабатываться так, чтобы нарисованная линия заканчивалась изящно. Вы можете заметить, что если вы очень быстро рисуете в виде зигзагообразного паттерна, то кривая может сильно защемиться в точках поворота кривой. Алгоритм изменения ширины можно сделать более сложным, чтобы толщина линии изменялась более реалистично, или его можно было просто испортить, чтобы получить забавные эффекты для детского приложения! Вы также можете изменять свойства Безье на каждой итерации цикла рисования. Например, вы можете ввести тонкие эффекты, слегка изменив цвет заливки и обводки, в дополнение к толщине обводки.

Я надеюсь, что этот урок оказался полезным и дал вам несколько свежих идей для вашего собственного приложения для рисования / черчения. Удачного кодирования!