iOS SDK: Técnicas avanzadas del dibujo a mano alzada
Spanish (Español) translation by Andrea Jiménez (you can also view the original English article)
Este tutorial se basará en los resultados de un tutorial anterior de Mobiletuts+ para crear una versión mejorada de la aplicación de dibujo en la que el grosor del trazo del lápiz cambia suavemente con la velocidad del dibujo del usuario y hace que el boceto resultante se vea aún más estilístico e interesante.
Resumen
Si aún no lo has hecho, te recomiendo que trabajes en el primer tutorial antes de comenzar este. Haré breves referencias a los conceptos y al código del primer tutorial, pero aquí no entraré en detalles.
En el primer tutorial implementamos un algoritmo que interpolaba los puntos de contacto adquiridos del usuario dibujando en la pantalla con su dedo, permitiendo al usuario dibujar en la pantalla. La interpolación se realizó con segmentos de curva de Bezier (proporcionados por la clase UIBezierPath en UIKit), con cuatro puntos de contacto consecutivos que comprenden un solo segmento de Bezier. Luego realizamos una operación de suavizado en el punto de unión que conecta dos segmentos adyacentes para lograr una curva general suave a mano alzada.
También recuerda que para mantener el rendimiento del dibujo y la capacidad de respuesta de la interfaz de usuario, (en instantes particulares) representaríamos el dibujo generado hasta ese punto en un mapa de bits. Esto nos liberó para restablecer nuestro UIBezierPath, evitando que nuestra aplicación se vuelva lenta y no responda debido a los cálculos excesivos de una ruta de crecimiento indefinida. Llevamos a cabo este paso cada vez que el usuario levantaba el dedo de la pantalla.
Ahora hablemos sobre nuestros objetivos para este tutorial. En principio, nuestro requisito es sencillo: a medida que el usuario dibuje con su dedo, realice un seguimiento de la rapidez con que se mueve su dedo y varíe el ancho del trazo del lápiz en consecuencia. La relación exacta entre la velocidad y el grosor que queremos que sea el trazo puede modificarse para lograr diferentes efectos estéticos.
Hacer un seguimiento de la velocidad de dibujo es bastante simple; la aplicación muestra el toque del usuario aproximadamente 60 veces por segundo (siempre que no haya desaceleración en el hilo principal), por lo que la velocidad instantánea del toque del usuario será proporcional a la distancia entre dos muestras táctiles consecutivas.
El enfoque obvio que se sugiere sería variar la propiedad lineWidth en la clase UIBezierPath con respecto a la velocidad de dibujo. Sin embargo, esta simple idea tiene un par de problemas y, en última instancia, no es lo suficientemente buena como para satisfacer nuestras demandas. Siguiendo el espíritu del primer tutorial, implementaremos este enfoque primero, para que podamos examinar sus deficiencias y pensar en mejorarlo iterativamente o eliminarlo por completo y probar otra cosa. ¡De todos modos así es como se desarrolla el código real!
A medida que desarrollemos nuestra aplicación, descubriremos que, debido a los requisitos nuevos y más complejos, nuestra aplicación se beneficiará si trasladamos algo de código al fondo, en particular, el código de dibujo de mapa de bits. Usaremos el GCD de Apple (Grand Central Dispatch) para eso.
¡Vamos a sumergirnos y a escribir un código!
1. Primer intento: un algoritmo "ingenuo"
Paso 1
Inicia Xcode y crea un nuevo proyecto con la plantilla "Aplicación vacía". Llámalo VariableStrokeWidthTut. Conviértelo en un proyecto universal y selecciona "Usar recuento automático de referencias" dejando las otras opciones sin seleccionar.



Paso 2
En el resumen del proyecto para ambos dispositivos, elije cualquier modo como la única orientación de interfaz compatible, no importa cuál. Yo elegí Retrato al revés en este tutorial. Es razonable que una aplicación de dibujo mantenga una sola orientación.






Como se discutió anteriormente, comenzaremos con la idea más simple posible, variando la propiedad lineWidth de UIBezierPath, y veremos qué nos brinda.
Paso 3
Crea un nuevo archivo, llamándolo NaiveVarWidthView y conviértelo en una subclase de UIView.
Reemplaza todo el código en NaiveVarWidthView.m con lo siguiente:
1 |
|
2 |
#import "NaiveVarWidthView.h"
|
3 |
|
4 |
@implementation NaiveVarWidthView |
5 |
{
|
6 |
UIBezierPath *path; |
7 |
UIImage *incrementalImage; |
8 |
CGPoint pts[5]; |
9 |
uint ctr; |
10 |
}
|
11 |
|
12 |
- (id)initWithFrame:(CGRect)frame |
13 |
{
|
14 |
self = [super initWithFrame:frame]; |
15 |
if (self) { |
16 |
[self setMultipleTouchEnabled:NO]; |
17 |
path = [UIBezierPath bezierPath]; |
18 |
}
|
19 |
return self; |
20 |
}
|
21 |
|
22 |
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event |
23 |
{
|
24 |
ctr = 0; |
25 |
UITouch *touch = [touches anyObject]; |
26 |
pts[0] = [touch locationInView:self]; |
27 |
}
|
28 |
|
29 |
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event |
30 |
{
|
31 |
UITouch *touch = [touches anyObject]; |
32 |
CGPoint p = [touch locationInView:self]; |
33 |
ctr++; |
34 |
pts[ctr] = p; |
35 |
if (ctr == 4) |
36 |
{
|
37 |
pts[3] = CGPointMake((pts[2].x + pts[4].x)/2.0, (pts[2].y + pts[4].y)/2.0); |
38 |
[path moveToPoint:pts[0]]; |
39 |
[path addCurveToPoint:pts[3] controlPoint1:pts[1] controlPoint2:pts[2]]; |
40 |
|
41 |
UIGraphicsBeginImageContextWithOptions(self.bounds.size, YES, 0.0); // ................. (1) |
42 |
|
43 |
if (!incrementalImage) |
44 |
{
|
45 |
UIBezierPath *rectpath = [UIBezierPath bezierPathWithRect:self.bounds]; |
46 |
[[UIColor whiteColor] setFill]; |
47 |
[rectpath fill]; |
48 |
}
|
49 |
[incrementalImage drawAtPoint:CGPointZero]; |
50 |
[[UIColor blackColor] setStroke]; |
51 |
|
52 |
float speed = 0.0; |
53 |
|
54 |
for (int i = 0; i < 3; i++) |
55 |
{
|
56 |
float dx = pts[i+1].x - pts[i].x; |
57 |
float dy = pts[i+1].y - pts[i].y; |
58 |
speed += sqrtf(dx * dx + dy * dy); |
59 |
} // ................. (2) |
60 |
|
61 |
#define FUDGE_FACTOR 100 // emperically determined
|
62 |
float width = FUDGE_FACTOR/speed; // ................. (3) |
63 |
|
64 |
[path setLineWidth:width]; |
65 |
[path stroke]; |
66 |
incrementalImage = UIGraphicsGetImageFromCurrentImageContext(); |
67 |
UIGraphicsEndImageContext(); |
68 |
[self setNeedsDisplay]; |
69 |
|
70 |
[path removeAllPoints]; // ................. (4) |
71 |
pts[0] = pts[3]; |
72 |
pts[1] = pts[4]; |
73 |
ctr = 1; |
74 |
|
75 |
}
|
76 |
}
|
77 |
|
78 |
- (void)drawRect:(CGRect)rect |
79 |
{
|
80 |
[incrementalImage drawInRect:rect]; |
81 |
}
|
82 |
|
83 |
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event |
84 |
{
|
85 |
[self setNeedsDisplay]; |
86 |
}
|
87 |
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event |
88 |
{
|
89 |
[self touchesEnded:touches withEvent:event]; |
90 |
}
|
91 |
|
92 |
@end
|
Este código solo tiene algunas modificaciones de la versión final de la aplicación del primer tutorial. Solo hablaré de las novedades aquí. Refiriéndome a los puntos en el código:
- (1) Estamos creando un mapa de bits fuera de la pantalla para renderizar (dibujar) como antes. Sin embargo, esta vez estamos haciendo el paso de renderizado fuera de la pantalla después de cada actualización de dibujo (es decir, después de cada muestreo de cuatro puntos de contacto, que llega a alrededor de 60/4 = 25 veces por segundo). ¿Por qué? Esto se debe a que una solo caso de
UIBezierPathpuede tener solo un valor delineWidth. Dado que nuestro objetivo es variar el ancho de la línea de acuerdo con la velocidad de dibujo, en lugar de tener una ruta larga de bezier a la que seguimos incrementando los puntos (como en el primer tutorial) necesitamos descomponer nuestra ruta en los segmentos más pequeños posibles para que cada uno pueda tener un valor delineWidthdiferente. Obviamente, dado que cuatro puntos van a definir un Bezier cúbico, nuestros segmentos no pueden ser más cortos que eso. Por lo tanto, tendríamos que asignar un nuevo objetoUIBezierPathpor cada cuatro puntos recibidos hasta que ocurra el paso de representación fuera de pantalla. Tendríamos que seguir asignando memoria para nuevosUIBezierPaths potencialmente indefinidos si solo hiciéramos la representación del mapa de bits debido a que el usuario levanta su dedo de la pantalla. En el otro extremo, podríamos hacer el paso de representación fuera de pantalla después de cada cuatro puntos adquiridos (o alrededor de 60/4 = 25 veces por segundo), por lo que solo necesitamos mantener la única instancia deUIBezierPathcon no más de cuatro puntos en ella , y eso es lo que hemos hecho aquí. También podríamos llegar a un compromiso, y hacer el paso de dibujo fuera de pantalla periódicamente pero con menos frecuencia, creando un nuevoUIBezierPathhasta que ese paso suceda. - (2) Estamos utilizando una heurística simple para el valor de "velocidad" calculando la distancia en línea recta entre puntos adyacentes como una aproximación (borrador) de la longitud de la curva de Bezier.
- (3) Estamos configurando el
lineWidthpara que sea el inverso de la velocidad de dibujo multiplicado por un "factor inventado" determinado experimentalmente (de modo que la línea tenga un ancho razonable a la velocidad promedio de dibujo con la que se espera que dibuje un usuario típico). - (4) Después del renderizado de mapa de bits fuera de la pantalla, podemos eliminar todos los puntos en nuestra instancia de
UIBezierPathy comenzar de nuevo. Para reiterar, este paso ocurre después de cada cuatro puntos de contacto adquiridos.
Paso 4
Pega el siguiente código en AppDelegate.m para configurar el controlador de vista y asignarle una vista que sea una instancia de NaiveVarWidthView.
1 |
|
2 |
#import "AppDelegate.h"
|
3 |
#import "NaiveVarWidthView.h"
|
4 |
|
5 |
@implementation AppDelegate |
6 |
|
7 |
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions |
8 |
{
|
9 |
self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]]; |
10 |
self.window.backgroundColor = [UIColor whiteColor]; |
11 |
UIViewController *vc = [[UIViewController alloc] init]; |
12 |
self.window.rootViewController = vc; |
13 |
vc.view = [[NaiveVarWidthView alloc] initWithFrame:self.window.bounds]; |
14 |
vc.view.frame = self.window.bounds; |
15 |
vc.view.backgroundColor = [UIColor whiteColor]; |
16 |
[self.window makeKeyAndVisible]; |
17 |
return YES; |
18 |
}
|
Paso 5
Construye la aplicación y ejecuta. Garabatea en tu dispositivo y observa cuidadosamente el resultado:



Aquí el ancho de línea definitivamente está cambiando con la variación en la velocidad de dibujo, pero los resultados no son realmente impresionantes. El ancho salta abruptamente en lugar de variar suavemente a lo largo de la curva como nos gustaría. Veamos estos problemas con más detalle:
Como discutimos anteriormente, la propiedad lineWidth es un valor fijo para una sola instancia de UIBezierPath y desafortunadamente no se puede hacer que varíe a lo largo de su longitud. A pesar de que estamos utilizando la ruta de Bezier más pequeña posible (con solo cuatro puntos), el incremento en el ancho del trazo solo tiene lugar en la unión de dos rutas adyacentes, dando lugar a una variación de ancho "irregular" en lugar de continua.
El segundo problema relacionado con la implementación es que, aunque Core Graphics utiliza el concepto abstracto de "puntos" para representar tamaños como lineWidth, en realidad nuestro "lienzo" actualmente está compuesto de píxeles discretos. Dependiendo de si nuestro dispositivo tiene una pantalla que no sea Retina o Retina, una unidad de longitud en términos de puntos corresponde a uno o dos píxeles respectivamente. A pesar de que, como cualquier buena API de dibujo vectorial, los algoritmos internos utilizados por Core Graphics emplean algunos "trucos" (como el suavizado) para representar visualmente anchos de línea no integrales, no es realista esperar dibujar líneas de grosor arbitrario, por ejemplo, una línea que tiene un ancho (digamos) 2.1 puntos probablemente se representará idénticamente a una línea de ancho 2.0 puntos. Por el contrario, solo se produce un cambio perceptible en la representación para un gran incremento en el valor de la propiedad lineWidth. Ten en cuenta que el problema de discretización es omnipresente, pero al mismo tiempo, el enfoque o algoritmo correcto puede marcar la diferencia.
Es posible que puedas mejorar los resultados ligeramente, jugando con el cálculo del lineWidth, etc., pero creo que este enfoque es fundamentalmente limitado y, por lo tanto, debemos abordar este problema desde una nueva perspectiva.
Antes de pasar a eso, analicemos el hecho de que ahora estamos haciendo el paso de renderizado fuera de la pantalla periódicamente (hasta 25 veces por segundo, de hecho) y, lo que es más importante, ahora lo estamos haciendo entre la adquisición del punto de contacto. En mi iPhone 4, determiné (usando un contador y un temporizador que se disparaba cada segundo) que esto estaba causando que la tasa de adquisición táctil cayera de 60-63 por segundo (para el código del primer tutorial) a alrededor de 48-52 por segundo, es una caída notable! Obviamente, esto representa una disminución en la capacidad de respuesta de la aplicación y degradará aún más la calidad de la interpolación, haciendo que la curva resultante se vea menos suave. Estrictamente hablando, deberíamos usar la herramienta Instrumentos para analizar el rendimiento de la aplicación, pero para los propósitos de este tutorial, digamos que lo hemos hecho y que hemos verificado que la operación de representación fuera de pantalla es lo que consume más tiempo.
El problema con nuestro código radica en los métodos touchesMoved:withEvent: después de adquirir cada cuarto punto de contacto, el control ingresa al cuerpo de la instrucción if, ejecuta el código de renderizado que consume mucho tiempo, y solo después de completarlo sale del cuerpo del método. Hasta que eso suceda, la interfaz del usuario no puede procesar el siguiente contacto.
Este tipo de problema, en términos generales, no es uno fuera de lo común. Tenemos una operación que consume mucho tiempo (en este caso, la representación fuera de pantalla) cuyo resultado (el mapa de bits) es útil solo después de que finaliza toda la operación. Al mismo tiempo, tenemos algunos eventos cortos pero frecuentes que no pueden tolerar la demora (aquí, la adquisición táctil). Si tenemos múltiples procesadores para ejecutar nuestro código, nos gustaría separar las dos "rutas de código" para que puedan ejecutarse independientemente, cada una en su propio procesador. Incluso si tenemos un único procesador que ejecuta nuestro código, nos gustaría organizar las cosas para que tengamos dos rutas de código separadas, con la ejecución de este último intercalado entre el primero, y el tiempo de programación del procesador para cada ruta de código de acuerdo con su tiempo y requisitos de prioridad. Con suerte, está claro que acabamos de describir el subprocesamiento múltiple (aunque de una manera muy simplificada).
Una de las pistas que tenemos de que el subprocesamiento múltiple será útil en esta situación es que debemos dibujar la imagen solo una vez por cada cuatro puntos de contacto consecutivos, por lo que, en realidad, si las cosas se organizan correctamente,hay más tiempo disponible para que se ejecute el código de dibujo de mapa de bits del que utilizamos anteriormente.
2. Mover el dibujo fuera de la pantalla al fondo con GCD
En términos generales, deseas alejar el código de representación del hilo principal responsable de dibujar en la pantalla y procesar los eventos del usuario. El SDK de iOS ofrece varias opciones para lograr esto, incluido el subproceso manual, el NSOperation y el Grand Central Dispatch (GCD). Aquí usaremos GCD. No es posible hablar sobre el GCD con detalles significativos en este tutorial, por lo que mi idea es explicar los bits que usamos mientras lo paso por el código. Creo que si comprendes el "patrón de diseño" que vamos a aplicar y cómo ayuda a resolver el problema en cuestión, podrás adaptarlo a otros problemas de naturaleza similar, por ejemplo, descargando grandes cantidades de Datos de Internet, realizando algunas operaciones de filtrado complejas en una imagen, etc. manteniendo la interfaz de usuario receptiva.
Paso 1
Crea una nueva subclase de UIView llamada NaiveVarWidthBGRenderingView.
Pega el siguiente código en NaiveVarWidthBGRenderingView.m:
1 |
|
2 |
#import "NaiveVarWidthBGRenderingView.h"
|
3 |
|
4 |
#define CAPACITY 100 // buffer capacity
|
5 |
|
6 |
@implementation NaiveVarWidthBGRenderingView |
7 |
{
|
8 |
|
9 |
UIImage *incrementalImage; |
10 |
CGPoint pts[5]; |
11 |
uint ctr; |
12 |
CGPoint pointsBuffer[CAPACITY]; // ................. (1) |
13 |
uint bufIdx; |
14 |
dispatch_queue_t drawingQueue; |
15 |
|
16 |
}
|
17 |
|
18 |
- (id)initWithFrame:(CGRect)frame |
19 |
{
|
20 |
self = [super initWithFrame:frame]; |
21 |
if (self) { |
22 |
[self setMultipleTouchEnabled:NO]; |
23 |
drawingQueue = dispatch_queue_create("drawingQueue", NULL); // ................. (2) |
24 |
}
|
25 |
return self; |
26 |
}
|
27 |
|
28 |
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event |
29 |
{
|
30 |
ctr = 0; |
31 |
bufIdx = 0; |
32 |
UITouch *touch = [touches anyObject]; |
33 |
pts[0] = [touch locationInView:self]; |
34 |
}
|
35 |
|
36 |
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event |
37 |
{
|
38 |
UITouch *touch = [touches anyObject]; |
39 |
CGPoint p = [touch locationInView:self]; |
40 |
ctr++; |
41 |
pts[ctr] = p; |
42 |
if (ctr == 4) |
43 |
{
|
44 |
pts[3] = CGPointMake((pts[2].x + pts[4].x)/2.0, (pts[2].y + pts[4].y)/2.0); |
45 |
pointsBuffer[bufIdx] = pts[0]; |
46 |
pointsBuffer[bufIdx + 1] = pts[1]; |
47 |
pointsBuffer[bufIdx + 2] = pts[2]; |
48 |
pointsBuffer[bufIdx + 3] = pts[3]; |
49 |
|
50 |
bufIdx += 4; |
51 |
|
52 |
CGRect bounds = self.bounds; |
53 |
dispatch_async(drawingQueue, ^{ // ................. (3) |
54 |
if (bufIdx == 0) return; // ................. (4) |
55 |
UIBezierPath *path = [UIBezierPath bezierPath]; |
56 |
for ( int i = 0; i < bufIdx; i += 4) |
57 |
{
|
58 |
[path moveToPoint:pointsBuffer[i]]; |
59 |
[path addCurveToPoint:pointsBuffer[i+3] controlPoint1:pointsBuffer[i+1] controlPoint2:pointsBuffer[i+2]]; |
60 |
}
|
61 |
|
62 |
UIGraphicsBeginImageContextWithOptions(bounds.size, YES, 0.0); |
63 |
|
64 |
if (!incrementalImage) // first time; paint background white |
65 |
{
|
66 |
UIBezierPath *rectpath = [UIBezierPath bezierPathWithRect:self.bounds]; |
67 |
[[UIColor whiteColor] setFill]; |
68 |
[rectpath fill]; |
69 |
}
|
70 |
[incrementalImage drawAtPoint:CGPointZero]; |
71 |
[[UIColor blackColor] setStroke]; |
72 |
|
73 |
float speed = 0.0; |
74 |
for (int i = 0; i < 3; i++) |
75 |
{
|
76 |
float dx = pts[i+1].x - pts[i].x; |
77 |
float dy = pts[i+1].y - pts[i].y; |
78 |
speed += sqrtf(dx * dx + dy * dy); |
79 |
}
|
80 |
|
81 |
#define FUDGE_FACTOR 100 // emperically determined
|
82 |
float width = FUDGE_FACTOR/speed; |
83 |
[path setLineWidth:width]; |
84 |
[path stroke]; |
85 |
incrementalImage = UIGraphicsGetImageFromCurrentImageContext(); |
86 |
UIGraphicsEndImageContext(); |
87 |
dispatch_async(dispatch_get_main_queue(), ^{ // ................. (5) |
88 |
bufIdx = 0; |
89 |
[self setNeedsDisplay]; |
90 |
});
|
91 |
});
|
92 |
pts[0] = pts[3]; |
93 |
pts[1] = pts[4]; |
94 |
ctr = 1; |
95 |
}
|
96 |
}
|
97 |
|
98 |
- (void)drawRect:(CGRect)rect |
99 |
{
|
100 |
[incrementalImage drawInRect:rect]; |
101 |
}
|
102 |
|
103 |
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event |
104 |
{
|
105 |
|
106 |
[self setNeedsDisplay]; |
107 |
}
|
108 |
|
109 |
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event |
110 |
{
|
111 |
[self touchesEnded:touches withEvent:event]; |
112 |
}
|
113 |
|
114 |
@end
|
Paso 2
Modifica AppDelegate.m por #include NaiveVarWidthBGRenderingView y para configurar el controlador de la vista raíz usa view como una instancia de NaiveVarWidthBGRenderingView. Simplemente reemplazar la cadena NaiveVarWidthView por NaiveVarWidthBGRenderingView en todas partes en AppDelegate.m hará el truco.
Ejecuta el código. Todavía no hemos tocado nuestro código de dibujo, por lo que no hay nada nuevo que ver. Esperamos que estés satisfecho sabiendo que tu código hace un uso más efectivo de los recursos de procesamiento de tu dispositivo y probablemente funciona mejor en dispositivos más antiguos. En mi iPhone 4, con la misma prueba descrita anteriormente, la velocidad de adquisición táctil volvió a su valor máximo (60-63 por segundo).
Ahora estudiemos el código, con referencia a los puntos numerados en la lista de códigos:
- (1) Hemos introducido una matriz para almacenar puntos entrantes,
pointsBuffer. En un momento explicaré exactamente el por qué. El tamaño del búfer (100) se eligió aleatoriamente; de hecho, no esperamos que este búfer se llene más allá de los cuatro puntos que pertenecen a un solo segmento de la curva de Bezier. Pero está ahí para manejar una determinada situación que pueda surgir. - (2) El GCD resume los hilos detrás del concepto de una cola. Enviamos tareas (unidades de trabajo) a las colas. Hay dos tipos de colas, concurrentes y seriales. Aquí solo hablaremos de las colas en serie, porque es el único tipo que estamos usando de manera explícita. Una cola en serie realiza las tareas que se le asignan estrictamente por orden de llegada, primera salida, de forma muy parecida a una cola de orden de llegada en un cajero de banco o en el cajero de un supermercado. La palabra "serial" también indica que una tarea se completará antes de que se ejecute la siguiente, al igual que un cajero en el supermercado no comenzará a atender al próximo cliente antes de que termine de atender al cliente actual. Aquí hemos creado una cola y le hemos asignado el identificador
drawingQueue. Es útil tener en cuenta que todo el código que normalmente escribimos se ejecuta tácitamente en la cola principal siempre existente, ¡que en sí misma es una cola en serie! Entonces ahora tenemos dos colas. Todavía no hemos programado ningún trabajo en la cola del dibujo. - (3) La llamada a la función
dispatch_async()se programa endrawingQueue, el código de dibujo del mapa de bits empaquetado en el bloque ^ {...}, de forma asíncrona. "Asíncrona" implica que, si bien la tarea se ha enviado, aún no se ha ejecutado. De hecho,dispatch_async()devuelve el control a la persona que llama de inmediato, en este caso, el cuerpo del método(-)touchesMoved:withEvent:(en la cola principal). Esto supone una diferencia fundamental con nuestra implementación anterior (no basada en hilos). ¡Antes todo sucedía en la cola principal y el código de dibujo del mapa de bits tenía que ejecutarse hasta su finalización antes de continuar! Asegúrate de comprender esta distinción. Con nuestra implementación actual, en un dispositivo multinúcleo es bastante posible que la cola del dibujo se cree en un núcleo diferente al que procesa la cola principal, y ambas colas se procesan simultáneamente, al igual que un pequeño supermercado que tiene dos cajeros, brindando servicio a dos colas de clientes al mismo tiempo. Para comprender cómo funcionan las cosas en un dispositivo con un solo procesador, mira la siguiente analogía: imagina una oficina con una sola fotocopiadora. El "muchacho de la fotocopiadora" tiene una gran cantidad de trabajo que ha recibido a granel, y se espera que tome todo el día para completarlo. Sin embargo, de vez en cuando uno de los empleados de la oficina le lleva algunas páginas para fotocopiar. Obviamente, lo más inteligente para él es interrumpir temporalmente el trabajo que le consume más tiempo y en el que ha estado durante todo el día, y completar la tarea específica (pero aparentemente urgente) que le envió el empleado y luego volver a sus tareas anteriores. En esta analogía, la necesidad breve pero urgente del empleado por las fotocopias se refiere a tareas de alta prioridad que aparecen en la cola principal, como eventos táctiles o dibujos en pantalla, mientras que el trabajo a granel se refiere a tareas que requieren mucho tiempo, como la descarga de datos del Internet o (en nuestro caso) dibujar en un búfer fuera de la pantalla. El sistema operativo se comporta como el muchacho inteligente de la fotocopiadora, programando tareas en un único procesador (una única fotocopiadora) de la manera que mejor satisfaga las necesidades de la aplicación (la oficina). (¡Espero que la analogía no haya sido demasiado cursi!) De todos modos, el código real enviado a la cola del dibujo es más o menos lo que teníamos en nuestra anterior implementación , excepto el uso de un búfer al que agregamos nuestros puntos de contacto, que discutiremos a continuación. - (4) Este pequeño código tiene que ver con nuestro uso de la matriz
pointsBuffer. Considera el escenario hipotético de que una tarea de dibujo fuera de la pantalla se agrega a la cola del dibujo, pero por alguna razón no tiene la oportunidad de ejecutarse, y mientras tanto en la cola principal se han adquirido los siguientes cuatro puntos de contacto y otra tarea del dibujo entra en la cola del dibujo, detrás de la primera. Quién sabe, tal vez nuestra aplicación era más compleja y también tenía otras cosas al mismo tiempo. Al proteger nuestros puntos de contacto, podemos asegurarnos de que, en el caso de las tareas de dibujo fuera de la pantalla con múltiples colas, la primera realiza todo el dibujo y las que siguen simplemente se devuelven debido a que el búfer de puntos está vacío. Como dije anteriormente, este escenario de la cola del dibujo que se respalda con dos o más tareas del dibujo, todas en espera de ser ejecutadas, podría no ocurrir en absoluto, y si ocurre de manera persistente, entonces podría significar que nuestro algoritmo era demasiado lento para el dispositivo, ya sea por su complejidad, diseño deficiente o porque nuestra aplicación intentaba hacer demasiadas cosas. Pero en caso de que suceda, lo hemos manejado. - (5) Todas las acciones de actualización de la interfaz de usuario deben realizarse en la cola principal, lo que haremos con otro envío asincrónico desde la tarea del dibujo en la cola del dibujo, como en la llamada anterior a
dispatch_async(), se ha enviado la tarea de actualizar la pantalla, pero esto no significa que la aplicación vaya a abandonar lo que está haciendo y ejecutarla en ese momento.
En general, el patrón que hemos implementado se ve así , y es aplicable a muchos otros escenarios:
1 |
|
2 |
// Main queue
|
3 |
dispatch_async(aSerialQueue, ^{ |
4 |
// background processing
|
5 |
dispatch_async(mainQueue, ^{ |
6 |
// update UI with results
|
7 |
});
|
8 |
});
|
Generalmente, escribir un código multiproceso puede que no sea una tarea fácil. Pero no siempre es tan complejo como podrías pensar (como indica nuestro propio ejemplo). A veces puede parecer una "tarea ingrata" porque no hay un "factor sorpresa" explícito que puedas mostrar al final. Pero siempre ten en cuenta que si la interfaz del usuario de tu aplicación funciona tan bien como la mantequilla, ¡es mucho más probable que tus usuarios disfruten usarla y vuelvan a ella una y otra vez!
3. Desarrollar un mejor algoritmo
En la primera iteración, determinamos que era poco probable que hiciéramos muchas mejoras al obtener un trazo de lápiz continuo y suave con variación del ancho con el enfoque "ingenuo" con el que habíamos comenzado. Ahora intentemos un nuevo enfoque.
El método que voy a presentar aquí es bastante sencillo, aunque requiere que pensemos de manera inmediata. En lugar de representar nuestro trazo dibujado con una curva de Bézier como lo estábamos haciendo anteriormente, ahora lo representaremos por la región rellena entre dos rutas de Bézier, cada ruta está ligeramente desplazada a cada lado de la curva imaginaria trazada por el dedo del usuario. Al variar ligeramente los desplazamientos de los puntos de control que definen estas dos curvas de Bezier, lograremos un efecto muy razonable de un ancho de lápiz que varía suavemente.



La figura anterior muestra la construcción descrita anteriormente para un solo segmento cúbico de Bezier. Las x con círculos rojos a su alrededor corresponderían a los puntos táctiles capturados y la curva marrón discontinua es el segmento Bézier generado a partir de estos puntos. Corresponde a la ruta de Bezier que dibujamos en nuestras implementaciones anteriores.
Para cada uno de los cuatro puntos de contacto, se genera un par de puntos de compensación, que se muestran en cada extremo del segmento de la línea verde. Estos segmentos de la línea verde están hechos para ser perpendiculares al segmento de la línea que une dos puntos táctiles adyacentes. De este modo, generamos dos conjuntos de cuatro puntos a cada lado del conjunto de los puntos de contacto, y cada uno de estos conjuntos de puntos de compensación se puede utilizar para generar una curva de Bezier de compensación que se ubicará a cada lado de la curva trazada de Bezier (las dos curvas marrones sólidas ) Debe quedar claro en la figura que la variación del ancho está controlada por las distancias de los puntos de desplazamiento (es decir, la longitud de los segmentos de las líneas verdes). Si llenamos la región entre estas dos curvas del desplazamiento, ¡hemos simulado efectivamente un "trazo" de anchura!
Este enfoque influye mejor en cómo funciona el dibujo vectorial dentro del marco Core Graphics/UIKit, porque se ejemplifica mejor la variación continua, en comparación con el enfoque "abrupto" de cambiar el ancho del trazo en el método "ingenuo", y en el resultado final, funciona bien .
La medida principal que necesitamos implementar es un método que nos pueda dar las coordenadas de estos puntos de desplazamiento. Especifiquemos el problema de manera más precisa y geométrica. Tenemos un segmento de línea que conecta los puntos p1 = (x1, y1) y p2 = (x2, y2), que denotaré como p1-p2. Nos gustaría encontrar una línea que pase por p2, perpendicular a p1-p2. Este problema es fácil de resolver si lo formulamos en términos de vectores. El segmento de línea p1-p2 puede representarse mediante la ecuación p = p1 + (p2 - p1)t, donde t es un parámetro variable. Al variar t de 0 a 1, p "barre" de p1 a p2 a lo largo de la línea recta que conecta los dos puntos. Los dos casos especiales son t = 0 correspondiente a p = p1, mientras que t = 1 corresponde a p = p2.
Podemos dividir esta ecuación paramétrica en términos de coordenadas x e y para obtener el par de ecuaciones x = x1 + t(x2 - x1) y y = y1 + t(y2 - y1), donde p = (x, y) . Necesitamos recurrir a un teorema de la geometría que establezca que el producto de las pendientes de dos líneas perpendiculares es -1. La pendiente de la línea entre (x1, y1) y (x2, y2) es igual a (y2-y1)/(x2-x1). Usando esta propiedad y alguna manipulación algebraica, podemos calcular los puntos finales pa y pb de la línea perpendicular a p1-p2, de modo que pa y pb estén a la misma distancia de p2. La longitud de pa-pb puede controlarse mediante una variable que expresa la relación de la longitud de esta línea a p1-p2. En lugar de escribir un montón de ecuaciones desordenadas, he dibujado una figura que debería aclararlo todo.

Paso 1
¡Implementemos estas ideas en código! Crea un FinalAlgView como una subclase de UIView y pega el siguiente código en él. Además, no olvides modificar AppDelegate.m para usar esta clase como la visualización del controlador de vista:
1 |
|
2 |
#define CAPACITY 100
|
3 |
#define FF .2
|
4 |
#define LOWER 0.01
|
5 |
#define UPPER 1.0
|
6 |
|
7 |
#import "FinalAlgView.h"
|
8 |
|
9 |
typedef struct |
10 |
{
|
11 |
CGPoint firstPoint; |
12 |
CGPoint secondPoint; |
13 |
} LineSegment; // ................. (1) |
14 |
|
15 |
@implementation FinalAlgView |
16 |
{
|
17 |
|
18 |
UIImage *incrementalImage; |
19 |
CGPoint pts[5]; |
20 |
uint ctr; |
21 |
CGPoint pointsBuffer[CAPACITY]; |
22 |
uint bufIdx; |
23 |
dispatch_queue_t drawingQueue; |
24 |
BOOL isFirstTouchPoint; |
25 |
LineSegment lastSegmentOfPrev; |
26 |
|
27 |
}
|
28 |
|
29 |
- (id)initWithFrame:(CGRect) frame |
30 |
{
|
31 |
self = [super initWithFrame:frame]; |
32 |
if (self) { |
33 |
|
34 |
[self setMultipleTouchEnabled:NO]; |
35 |
drawingQueue = dispatch_queue_create("drawingQueue", NULL); |
36 |
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(eraseDrawing:)]; |
37 |
tap.numberOfTapsRequired = 2; // Tap twice to clear drawing! |
38 |
[self addGestureRecognizer:tap]; |
39 |
|
40 |
}
|
41 |
return self; |
42 |
}
|
43 |
|
44 |
- (void)eraseDrawing:(UITapGestureRecognizer *)t |
45 |
{
|
46 |
incrementalImage = nil; |
47 |
[self setNeedsDisplay]; |
48 |
}
|
49 |
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event |
50 |
{
|
51 |
ctr = 0; |
52 |
bufIdx = 0; |
53 |
UITouch *touch = [touches anyObject]; |
54 |
pts[0] = [touch locationInView:self]; |
55 |
isFirstTouchPoint = YES; |
56 |
}
|
57 |
|
58 |
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event |
59 |
{
|
60 |
UITouch *touch = [touches anyObject]; |
61 |
CGPoint p = [touch locationInView:self]; |
62 |
ctr++; |
63 |
pts[ctr] = p; |
64 |
if (ctr == 4) |
65 |
{
|
66 |
pts[3] = CGPointMake((pts[2].x + pts[4].x)/2.0, (pts[2].y + pts[4].y)/2.0); |
67 |
|
68 |
for ( int i = 0; i < 4; i++) |
69 |
{
|
70 |
pointsBuffer[bufIdx + i] = pts[i]; |
71 |
}
|
72 |
|
73 |
bufIdx += 4; |
74 |
|
75 |
CGRect bounds = self.bounds; |
76 |
|
77 |
dispatch_async(drawingQueue, ^{ |
78 |
UIBezierPath *offsetPath = [UIBezierPath bezierPath]; // ................. (2) |
79 |
if (bufIdx == 0) return; |
80 |
|
81 |
LineSegment ls[4]; |
82 |
for ( int i = 0; i < bufIdx; i += 4) |
83 |
{
|
84 |
if (isFirstTouchPoint) // ................. (3) |
85 |
{
|
86 |
ls[0] = (LineSegment){pointsBuffer[0], pointsBuffer[0]}; |
87 |
[offsetPath moveToPoint:ls[0].firstPoint]; |
88 |
isFirstTouchPoint = NO; |
89 |
}
|
90 |
|
91 |
else
|
92 |
ls[0] = lastSegmentOfPrev; |
93 |
|
94 |
float frac1 = FF/clamp(len_sq(pointsBuffer[i], pointsBuffer[i+1]), LOWER, UPPER); // ................. (4) |
95 |
float frac2 = FF/clamp(len_sq(pointsBuffer[i+1], pointsBuffer[i+2]), LOWER, UPPER); |
96 |
float frac3 = FF/clamp(len_sq(pointsBuffer[i+2], pointsBuffer[i+3]), LOWER, UPPER); |
97 |
ls[1] = [self lineSegmentPerpendicularTo:(LineSegment){pointsBuffer[i], pointsBuffer[i+1]} ofRelativeLength:frac1]; // ................. (5) |
98 |
ls[2] = [self lineSegmentPerpendicularTo:(LineSegment){pointsBuffer[i+1], pointsBuffer[i+2]} ofRelativeLength:frac2]; |
99 |
ls[3] = [self lineSegmentPerpendicularTo:(LineSegment){pointsBuffer[i+2], pointsBuffer[i+3]} ofRelativeLength:frac3]; |
100 |
|
101 |
[offsetPath moveToPoint:ls[0].firstPoint]; // ................. (6) |
102 |
[offsetPath addCurveToPoint:ls[3].firstPoint controlPoint1:ls[1].firstPoint controlPoint2:ls[2].firstPoint]; |
103 |
[offsetPath addLineToPoint:ls[3].secondPoint]; |
104 |
[offsetPath addCurveToPoint:ls[0].secondPoint controlPoint1:ls[2].secondPoint controlPoint2:ls[1].secondPoint]; |
105 |
[offsetPath closePath]; |
106 |
|
107 |
lastSegmentOfPrev = ls[3]; // ................. (7) |
108 |
// Suggestion: Apply smoothing on the shared line segment of the two adjacent offsetPaths
|
109 |
|
110 |
}
|
111 |
UIGraphicsBeginImageContextWithOptions(bounds.size, YES, 0.0); |
112 |
|
113 |
if (!incrementalImage) |
114 |
{
|
115 |
UIBezierPath *rectpath = [UIBezierPath bezierPathWithRect:self.bounds]; |
116 |
[[UIColor whiteColor] setFill]; |
117 |
[rectpath fill]; |
118 |
}
|
119 |
[incrementalImage drawAtPoint:CGPointZero]; |
120 |
[[UIColor blackColor] setStroke]; |
121 |
[[UIColor blackColor] setFill]; |
122 |
[offsetPath stroke]; // ................. (8) |
123 |
[offsetPath fill]; |
124 |
incrementalImage = UIGraphicsGetImageFromCurrentImageContext(); |
125 |
UIGraphicsEndImageContext(); |
126 |
[offsetPath removeAllPoints]; |
127 |
dispatch_async(dispatch_get_main_queue(), ^{ |
128 |
bufIdx = 0; |
129 |
[self setNeedsDisplay]; |
130 |
});
|
131 |
});
|
132 |
pts[0] = pts[3]; |
133 |
pts[1] = pts[4]; |
134 |
ctr = 1; |
135 |
}
|
136 |
}
|
137 |
|
138 |
- (void)drawRect:(CGRect)rect |
139 |
{
|
140 |
[incrementalImage drawInRect:rect]; |
141 |
}
|
142 |
|
143 |
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event |
144 |
{
|
145 |
// Left as an exercise!
|
146 |
|
147 |
[self setNeedsDisplay]; |
148 |
}
|
149 |
|
150 |
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event |
151 |
{
|
152 |
[self touchesEnded:touches withEvent:event]; |
153 |
}
|
154 |
|
155 |
-(LineSegment) lineSegmentPerpendicularTo: (LineSegment)pp ofRelativeLength:(float)fraction |
156 |
{
|
157 |
CGFloat x0 = pp.firstPoint.x, y0 = pp.firstPoint.y, x1 = pp.secondPoint.x, y1 = pp.secondPoint.y; |
158 |
|
159 |
CGFloat dx, dy; |
160 |
dx = x1 - x0; |
161 |
dy = y1 - y0; |
162 |
|
163 |
CGFloat xa, ya, xb, yb; |
164 |
xa = x1 + fraction/2 * dy; |
165 |
ya = y1 - fraction/2 * dx; |
166 |
xb = x1 - fraction/2 * dy; |
167 |
yb = y1 + fraction/2 * dx; |
168 |
|
169 |
return (LineSegment){ (CGPoint){xa, ya}, (CGPoint){xb, yb} }; |
170 |
|
171 |
}
|
172 |
|
173 |
float len_sq(CGPoint p1, CGPoint p2) |
174 |
{
|
175 |
float dx = p2.x - p1.x; |
176 |
float dy = p2.y - p1.y; |
177 |
return dx * dx + dy * dy; |
178 |
}
|
179 |
|
180 |
float clamp(float value, float lower, float higher) |
181 |
{
|
182 |
if (value < lower) return lower; |
183 |
if (value > higher) return higher; |
184 |
return value; |
185 |
}
|
186 |
|
187 |
@end
|
Estudiemos este código, nuevamente con referencia a los comentarios enumerados:
- (1)
LineSegmentes una estructura simple en C que ha sidotypedef'd para empaquetar convenientemente los dos CGPoints al final de un segmento de línea. Nada especial - (2) El
offsetPathes la ruta que rellenaremos y trazaremos para lograr nuestro trazo de lápiz variable y grueso. Consistirá en una ruta cerrada (lo que significa que su primer punto se conectará al último para que se pueda llenar), que consta de dos subrutas de Bezier desplazadas a cada lado de la ruta trazada más dos segmentos de línea recta que conectan los extremos correspondientes de los dos subtrayectos. - (3) Aquí estamos tratando con el caso especial del primer toque cuando el usuario pone su dedo en la vista. No crearemos puntos de compensación para este primer punto.
- (4) Este es el factor utilizado para relacionar la velocidad del dibujo (tomando la distancia entre dos puntos de contacto como representando la velocidad del usuario). La función
len_sq()retorna la distancia al cuadrado entre dos puntos. ¿Por qué la distancia al cuadrado? Lo explicaré en el siguiente punto. Como siempre,FFes un "factor de fraude" que decidí después de prueba y error con el fin de obtener resultados visualmente agradables. La funciónclamp()evita que el valor del argumento pase por debajo o por encima de los umbrales establecidos, para evitar que el trazo del lápiz se vuelva demasiado grueso o demasiado delgado. Nuevamente, los valores INFERIOR y SUPERIOR se eligieron después de algún ensayo y error. - (5) Creamos el método
(-)lineSegmentPerpendicularTo:ofRelativeLength:para implementar la idea geométrica en la que se basa nuestro enfoque, como se discutió anteriormente. El primer argumento corresponde ap1-p2de la figura. De la figura, observa que cuanto más largo seap1-p2, más largo serápa-pb(en términos absolutos). Entonces, al hacerfinversamente proporcional a la longitud dep1-p2, "cancelaremos" esta dependencia de la longitud, de modo que, por ejemplo,f = 0.5/length(p1-p2)hará quepa-pbtenga la longitud de 1 punto, independiente de la longitud dep1-p2. Para que la longitud depa-pbvaríe de acuerdo con la longitud dep1-p2, lo he dividido por la longitud dep1-p2nuevamente. Esta es la motivación para el factor de longitud al cuadrado inverso del punto anterior. - (6) Esto simplemente construye la ruta cerrada al unir dos subrutas de Bezier y dos segmentos de la línea recta. Ten en cuenta que las subrutas que comprenden el
offsetPathdeben agregarse en una secuencia particular, de modo que cada subruta comienza desde el último punto de la anterior. En particular observa la dirección del segundo segmento cúbico de Bezier. Puedes trazar la forma de unoffsetPathtípico siguiendo la secuencia en el código para comprender cómo se forma. - (7) Esto solo impone continuidad entre dos
offsetPathadyacentes. - (8) Ambos trazamos y llenamos la ruta. Si no trazamos, los segmentos adyacentes de
offsetPatha veces parecen no contiguos.
Paso 2
Compila la aplicación y ejecútala. Creo que estarás de acuerdo en que la variación sutil del ancho de la línea esbozada a medida que dibuja crea un interesante efecto estilístico.



A modo de comparación, esto es lo que fue el efecto final con el algoritmo del ancho de trazo fijo del tutorial original:



Conclusión
Comenzamos con una aplicación de dibujos a mano alzada, la mejoramos para incorporar subprocesos múltiples e introdujimos un efecto estilístico en el algoritmo del dibujo. Como siempre, hay margen de mejora. La terminación táctil (es decir, cuando el usuario levanta el dedo después de haber dibujado) debe manejarse para que la línea esbozada finalice correctamente. Puedes observar que si estás garabateando muy rápido en una especie de patrón en zigzag, entonces la curva puede quedar bastante pellizcada en los puntos de giro de la curva. ¡El algoritmo de variación de anchura se puede hacer más sofisticado para que el grosor de la línea varíe de manera más realista, o simplemente se pueda confundir para obtener algunos efectos divertidos para una aplicación para niños! También puedes variar las propiedades del Bézier en cada iteración del ciclo del dibujo. Por ejemplo, puedes introducir efectos sutiles variando ligeramente el color del relleno y el trazo, además del grosor del trazo.
Espero que este tutorial te haya resultado beneficioso y que te haya dado algunas ideas nuevas para tu propia aplicación de dibujo/bocetos. ¡Feliz codificación!



